mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	Merge remote-tracking branch 'inventree/master'
This commit is contained in:
		| @@ -5,6 +5,7 @@ Helper forms which subclass Django forms to provide additional functionality | |||||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||||
| from __future__ import unicode_literals | from __future__ import unicode_literals | ||||||
|  |  | ||||||
|  | from django.utils.translation import ugettext as _ | ||||||
| from django import forms | from django import forms | ||||||
| from crispy_forms.helper import FormHelper | from crispy_forms.helper import FormHelper | ||||||
| from crispy_forms.layout import Layout, Field | from crispy_forms.layout import Layout, Field | ||||||
| @@ -92,6 +93,20 @@ class HelperForm(forms.ModelForm): | |||||||
|         self.helper.layout = Layout(*layouts) |         self.helper.layout = Layout(*layouts) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ConfirmForm(forms.Form): | ||||||
|  |     """ Generic confirmation form """ | ||||||
|  |  | ||||||
|  |     confirm = forms.BooleanField( | ||||||
|  |         required=False, initial=False, | ||||||
|  |         help_text=_("Confirm") | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         fields = [ | ||||||
|  |             'confirm' | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class DeleteForm(forms.Form): | class DeleteForm(forms.Form): | ||||||
|     """ Generic deletion form which provides simple user confirmation |     """ Generic deletion form which provides simple user confirmation | ||||||
|     """ |     """ | ||||||
| @@ -99,7 +114,7 @@ class DeleteForm(forms.Form): | |||||||
|     confirm_delete = forms.BooleanField( |     confirm_delete = forms.BooleanField( | ||||||
|         required=False, |         required=False, | ||||||
|         initial=False, |         initial=False, | ||||||
|         help_text='Confirm item deletion' |         help_text=_('Confirm item deletion') | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
| @@ -131,14 +146,14 @@ class SetPasswordForm(HelperForm): | |||||||
|                                      required=True, |                                      required=True, | ||||||
|                                      initial='', |                                      initial='', | ||||||
|                                      widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), |                                      widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), | ||||||
|                                      help_text='Enter new password') |                                      help_text=_('Enter new password')) | ||||||
|  |  | ||||||
|     confirm_password = forms.CharField(max_length=100, |     confirm_password = forms.CharField(max_length=100, | ||||||
|                                        min_length=8, |                                        min_length=8, | ||||||
|                                        required=True, |                                        required=True, | ||||||
|                                        initial='', |                                        initial='', | ||||||
|                                        widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), |                                        widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), | ||||||
|                                        help_text='Confirm new password') |                                        help_text=_('Confirm new password')) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = User |         model = User | ||||||
|   | |||||||
| @@ -72,6 +72,27 @@ if DEBUG: | |||||||
|         format='%(asctime)s %(levelname)s %(message)s', |         format='%(asctime)s %(levelname)s %(message)s', | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  | # Web URL endpoint for served static files | ||||||
|  | STATIC_URL = '/static/' | ||||||
|  |  | ||||||
|  | # The filesystem location for served static files | ||||||
|  | STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static'))) | ||||||
|  |  | ||||||
|  | STATICFILES_DIRS = [ | ||||||
|  |     os.path.join(BASE_DIR, 'InvenTree', 'static'), | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | # Web URL endpoint for served media files | ||||||
|  | MEDIA_URL = '/media/' | ||||||
|  |  | ||||||
|  | # The filesystem location for served static files | ||||||
|  | MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media'))) | ||||||
|  |  | ||||||
|  | if DEBUG: | ||||||
|  |     print("InvenTree running in DEBUG mode") | ||||||
|  |     print("MEDIA_ROOT:", MEDIA_ROOT) | ||||||
|  |     print("STATIC_ROOT:", STATIC_ROOT) | ||||||
|  |  | ||||||
| # Does the user wish to use the sentry.io integration? | # Does the user wish to use the sentry.io integration? | ||||||
| sentry_opts = CONFIG.get('sentry', {}) | sentry_opts = CONFIG.get('sentry', {}) | ||||||
|  |  | ||||||
| @@ -106,12 +127,13 @@ INSTALLED_APPS = [ | |||||||
|     'django.contrib.staticfiles', |     'django.contrib.staticfiles', | ||||||
|  |  | ||||||
|     # InvenTree apps |     # InvenTree apps | ||||||
|     'common.apps.CommonConfig', |  | ||||||
|     'part.apps.PartConfig', |  | ||||||
|     'stock.apps.StockConfig', |  | ||||||
|     'company.apps.CompanyConfig', |  | ||||||
|     'build.apps.BuildConfig', |     'build.apps.BuildConfig', | ||||||
|  |     'common.apps.CommonConfig', | ||||||
|  |     'company.apps.CompanyConfig', | ||||||
|     'order.apps.OrderConfig', |     'order.apps.OrderConfig', | ||||||
|  |     'part.apps.PartConfig', | ||||||
|  |     'report.apps.ReportConfig', | ||||||
|  |     'stock.apps.StockConfig', | ||||||
|  |  | ||||||
|     # Third part add-ons |     # Third part add-ons | ||||||
|     'django_filters',               # Extended filter functionality |     'django_filters',               # Extended filter functionality | ||||||
| @@ -126,6 +148,7 @@ INSTALLED_APPS = [ | |||||||
|     'mptt',                         # Modified Preorder Tree Traversal |     'mptt',                         # Modified Preorder Tree Traversal | ||||||
|     'markdownx',                    # Markdown editing |     'markdownx',                    # Markdown editing | ||||||
|     'markdownify',                  # Markdown template rendering |     'markdownify',                  # Markdown template rendering | ||||||
|  |     'django_tex',                   # LaTeX output | ||||||
| ] | ] | ||||||
|  |  | ||||||
| LOGGING = { | LOGGING = { | ||||||
| @@ -160,7 +183,11 @@ ROOT_URLCONF = 'InvenTree.urls' | |||||||
| TEMPLATES = [ | TEMPLATES = [ | ||||||
|     { |     { | ||||||
|         'BACKEND': 'django.template.backends.django.DjangoTemplates', |         'BACKEND': 'django.template.backends.django.DjangoTemplates', | ||||||
|         'DIRS': [os.path.join(BASE_DIR, 'templates')], |         'DIRS': [ | ||||||
|  |             os.path.join(BASE_DIR, 'templates'), | ||||||
|  |             # Allow templates in the reporting directory to be accessed | ||||||
|  |             os.path.join(MEDIA_ROOT, 'report'), | ||||||
|  |         ], | ||||||
|         'APP_DIRS': True, |         'APP_DIRS': True, | ||||||
|         'OPTIONS': { |         'OPTIONS': { | ||||||
|             'context_processors': [ |             'context_processors': [ | ||||||
| @@ -173,6 +200,14 @@ TEMPLATES = [ | |||||||
|             ], |             ], | ||||||
|         }, |         }, | ||||||
|     }, |     }, | ||||||
|  |     # Backend for LaTeX report rendering | ||||||
|  |     { | ||||||
|  |         'NAME': 'tex', | ||||||
|  |         'BACKEND': 'django_tex.engine.TeXEngine', | ||||||
|  |         'DIRS': [ | ||||||
|  |             os.path.join(MEDIA_ROOT, 'report'), | ||||||
|  |         ] | ||||||
|  |     }, | ||||||
| ] | ] | ||||||
|  |  | ||||||
| REST_FRAMEWORK = { | REST_FRAMEWORK = { | ||||||
| @@ -315,31 +350,22 @@ DATE_INPUT_FORMATS = [ | |||||||
|     "%Y-%m-%d", |     "%Y-%m-%d", | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  | # LaTeX rendering settings (django-tex) | ||||||
|  | LATEX_SETTINGS = CONFIG.get('latex', {}) | ||||||
|  |  | ||||||
| # Static files (CSS, JavaScript, Images) | # Is LaTeX rendering enabled? (Off by default) | ||||||
| # https://docs.djangoproject.com/en/1.10/howto/static-files/ | LATEX_ENABLED = LATEX_SETTINGS.get('enabled', False) | ||||||
|  |  | ||||||
| # Web URL endpoint for served static files | # Set the latex interpreter in the config.yaml settings file | ||||||
| STATIC_URL = '/static/' | LATEX_INTERPRETER = LATEX_SETTINGS.get('interpreter', 'pdflatex') | ||||||
|  |  | ||||||
| # The filesystem location for served static files | LATEX_INTERPRETER_OPTIONS = LATEX_SETTINGS.get('options', '') | ||||||
| STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static'))) |  | ||||||
|  |  | ||||||
| STATICFILES_DIRS = [ | LATEX_GRAPHICSPATH = [ | ||||||
|     os.path.join(BASE_DIR, 'InvenTree', 'static'), |     # Allow LaTeX files to access the report assets directory | ||||||
|  |     os.path.join(MEDIA_ROOT, "report", "assets"), | ||||||
| ] | ] | ||||||
|  |  | ||||||
| # Web URL endpoint for served media files |  | ||||||
| MEDIA_URL = '/media/' |  | ||||||
|  |  | ||||||
| # The filesystem location for served static files |  | ||||||
| MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media'))) |  | ||||||
|  |  | ||||||
| if DEBUG: |  | ||||||
|     print("InvenTree running in DEBUG mode") |  | ||||||
|     print("MEDIA_ROOT:", MEDIA_ROOT) |  | ||||||
|     print("STATIC_ROOT:", STATIC_ROOT) |  | ||||||
|  |  | ||||||
| # crispy forms use the bootstrap templates | # crispy forms use the bootstrap templates | ||||||
| CRISPY_TEMPLATE_PACK = 'bootstrap3' | CRISPY_TEMPLATE_PACK = 'bootstrap3' | ||||||
|  |  | ||||||
|   | |||||||
| @@ -272,8 +272,9 @@ function setupFilterList(tableKey, table, target) { | |||||||
|     for (var key in filters) { |     for (var key in filters) { | ||||||
|         var value = getFilterOptionValue(tableKey, key, filters[key]); |         var value = getFilterOptionValue(tableKey, key, filters[key]); | ||||||
|         var title = getFilterTitle(tableKey, key); |         var title = getFilterTitle(tableKey, key); | ||||||
|  |         var description = getFilterDescription(tableKey, key); | ||||||
|          |          | ||||||
|         element.append(`<div class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`); |         element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Add a callback for adding a new filter |     // Add a callback for adding a new filter | ||||||
| @@ -362,6 +363,15 @@ function getFilterTitle(tableKey, filterKey) { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Return the pretty description for the given table and filter selection | ||||||
|  |  */ | ||||||
|  | function getFilterDescription(tableKey, filterKey) { | ||||||
|  |     var settings = getFilterSettings(tableKey, filterKey); | ||||||
|  |  | ||||||
|  |     return settings.title; | ||||||
|  | } | ||||||
|  |  | ||||||
| /* | /* | ||||||
|  * Return a description for the given table and filter selection. |  * Return a description for the given table and filter selection. | ||||||
|  */ |  */ | ||||||
|   | |||||||
| @@ -166,6 +166,13 @@ class AjaxMixin(object): | |||||||
|             except AttributeError: |             except AttributeError: | ||||||
|                 context = {} |                 context = {} | ||||||
|  |  | ||||||
|  |         # If no 'form' argument is supplied, look at the underlying class | ||||||
|  |         if form is None: | ||||||
|  |             try: | ||||||
|  |                 form = self.get_form() | ||||||
|  |             except AttributeError: | ||||||
|  |                 pass | ||||||
|  |  | ||||||
|         if form: |         if form: | ||||||
|             context['form'] = form |             context['form'] = form | ||||||
|         else: |         else: | ||||||
|   | |||||||
| @@ -73,3 +73,16 @@ log_queries: False | |||||||
| sentry: | sentry: | ||||||
|   enabled: False |   enabled: False | ||||||
|   # dsn: add-your-sentry-dsn-here |   # dsn: add-your-sentry-dsn-here | ||||||
|  |  | ||||||
|  | # LaTeX report rendering | ||||||
|  | # InvenTree uses the django-tex plugin to enable LaTeX report rendering | ||||||
|  | # Ref: https://pypi.org/project/django-tex/ | ||||||
|  | # Note: Ensure that a working LaTeX toolchain is installed and working *before* starting the server | ||||||
|  | latex: | ||||||
|  |   # Select the LaTeX interpreter to use for PDF rendering | ||||||
|  |   # Note: The intepreter needs to be installed on the system! | ||||||
|  |   # e.g. to install pdflatex: apt-get texlive-latex-base | ||||||
|  |   enabled: False | ||||||
|  |   interpreter: pdflatex  | ||||||
|  |   # Extra options to pass through to the LaTeX interpreter | ||||||
|  |   options: '' | ||||||
| @@ -39,7 +39,10 @@ class EditPartTestTemplateForm(HelperForm): | |||||||
|         fields = [ |         fields = [ | ||||||
|             'part', |             'part', | ||||||
|             'test_name', |             'test_name', | ||||||
|             'required' |             'description', | ||||||
|  |             'required', | ||||||
|  |             'requires_value', | ||||||
|  |             'requires_attachment', | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										33
									
								
								InvenTree/part/migrations/0042_auto_20200518_0900.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								InvenTree/part/migrations/0042_auto_20200518_0900.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | |||||||
|  | # Generated by Django 3.0.5 on 2020-05-18 09:00 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ('part', '0041_auto_20200517_0348'), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name='parttesttemplate', | ||||||
|  |             name='description', | ||||||
|  |             field=models.CharField(help_text='Enter description for this test', max_length=100, null=True, verbose_name='Test Description'), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name='parttesttemplate', | ||||||
|  |             name='requires_attachment', | ||||||
|  |             field=models.BooleanField(default=False, help_text='Does this test require a file attachment when adding a test result?', verbose_name='Requires Attachment'), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name='parttesttemplate', | ||||||
|  |             name='requires_value', | ||||||
|  |             field=models.BooleanField(default=False, help_text='Does this test require a value when adding a test result?', verbose_name='Requires Value'), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name='parttesttemplate', | ||||||
|  |             name='test_name', | ||||||
|  |             field=models.CharField(help_text='Enter a name for the test', max_length=100, verbose_name='Test Name'), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @@ -41,6 +41,7 @@ from InvenTree.helpers import decimal2string, normalize | |||||||
|  |  | ||||||
| from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus | from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus | ||||||
|  |  | ||||||
|  | from report import models as ReportModels | ||||||
| from build import models as BuildModels | from build import models as BuildModels | ||||||
| from order import models as OrderModels | from order import models as OrderModels | ||||||
| from company.models import SupplierPart | from company.models import SupplierPart | ||||||
| @@ -358,6 +359,24 @@ class Part(MPTTModel): | |||||||
|         self.category = category |         self.category = category | ||||||
|         self.save() |         self.save() | ||||||
|  |  | ||||||
|  |     def get_test_report_templates(self): | ||||||
|  |         """ | ||||||
|  |         Return all the TestReport template objects which map to this Part. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         templates = [] | ||||||
|  |  | ||||||
|  |         for report in ReportModels.TestReport.objects.all(): | ||||||
|  |             if report.matches_part(self): | ||||||
|  |                 templates.append(report) | ||||||
|  |  | ||||||
|  |         return templates | ||||||
|  |  | ||||||
|  |     def has_test_report_templates(self): | ||||||
|  |         """ Return True if this part has a TestReport defined """ | ||||||
|  |  | ||||||
|  |         return len(self.get_test_report_templates()) > 0 | ||||||
|  |  | ||||||
|     def get_absolute_url(self): |     def get_absolute_url(self): | ||||||
|         """ Return the web URL for viewing this part """ |         """ Return the web URL for viewing this part """ | ||||||
|         return reverse('part-detail', kwargs={'pk': self.id}) |         return reverse('part-detail', kwargs={'pk': self.id}) | ||||||
| @@ -1014,6 +1033,9 @@ class Part(MPTTModel): | |||||||
|         # Return the tests which are required by this part |         # Return the tests which are required by this part | ||||||
|         return self.getTestTemplates(required=True) |         return self.getTestTemplates(required=True) | ||||||
|  |  | ||||||
|  |     def requiredTestCount(self): | ||||||
|  |         return self.getRequiredTests().count() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def attachment_count(self): |     def attachment_count(self): | ||||||
|         """ Count the number of attachments for this part. |         """ Count the number of attachments for this part. | ||||||
| @@ -1087,6 +1109,17 @@ class Part(MPTTModel): | |||||||
|  |  | ||||||
|         return self.parameters.order_by('template__name') |         return self.parameters.order_by('template__name') | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def has_variants(self): | ||||||
|  |         """ Check if this Part object has variants underneath it. """ | ||||||
|  |  | ||||||
|  |         return self.get_all_variants().count() > 0 | ||||||
|  |  | ||||||
|  |     def get_all_variants(self): | ||||||
|  |         """ Return all Part object which exist as a variant under this part. """ | ||||||
|  |  | ||||||
|  |         return self.get_descendants(include_self=False) | ||||||
|  |  | ||||||
|  |  | ||||||
| def attach_file(instance, filename): | def attach_file(instance, filename): | ||||||
|     """ Function for storing a file for a PartAttachment |     """ Function for storing a file for a PartAttachment | ||||||
| @@ -1204,16 +1237,34 @@ class PartTestTemplate(models.Model): | |||||||
|  |  | ||||||
|     test_name = models.CharField( |     test_name = models.CharField( | ||||||
|         blank=False, max_length=100, |         blank=False, max_length=100, | ||||||
|         verbose_name=_("Test name"), |         verbose_name=_("Test Name"), | ||||||
|         help_text=_("Enter a name for the test") |         help_text=_("Enter a name for the test") | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     description = models.CharField( | ||||||
|  |         blank=False, null=True, max_length=100, | ||||||
|  |         verbose_name=_("Test Description"), | ||||||
|  |         help_text=_("Enter description for this test") | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     required = models.BooleanField( |     required = models.BooleanField( | ||||||
|         default=True, |         default=True, | ||||||
|         verbose_name=_("Required"), |         verbose_name=_("Required"), | ||||||
|         help_text=_("Is this test required to pass?") |         help_text=_("Is this test required to pass?") | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     requires_value = models.BooleanField( | ||||||
|  |         default=False, | ||||||
|  |         verbose_name=_("Requires Value"), | ||||||
|  |         help_text=_("Does this test require a value when adding a test result?") | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     requires_attachment = models.BooleanField( | ||||||
|  |         default=False, | ||||||
|  |         verbose_name=_("Requires Attachment"), | ||||||
|  |         help_text=_("Does this test require a file attachment when adding a test result?") | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class PartParameterTemplate(models.Model): | class PartParameterTemplate(models.Model): | ||||||
|     """ |     """ | ||||||
| @@ -1299,6 +1350,11 @@ class BomItem(models.Model): | |||||||
|         checksum: Validation checksum for the particular BOM line item |         checksum: Validation checksum for the particular BOM line item | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|  |     def save(self, *args, **kwargs): | ||||||
|  |  | ||||||
|  |         self.clean() | ||||||
|  |         super().save(*args, **kwargs) | ||||||
|  |  | ||||||
|     def get_absolute_url(self): |     def get_absolute_url(self): | ||||||
|         return reverse('bom-item-detail', kwargs={'pk': self.id}) |         return reverse('bom-item-detail', kwargs={'pk': self.id}) | ||||||
|  |  | ||||||
| @@ -1391,6 +1447,16 @@ class BomItem(models.Model): | |||||||
|         - A part cannot refer to a part which refers to it |         - A part cannot refer to a part which refers to it | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|  |         # If the sub_part is 'trackable' then the 'quantity' field must be an integer | ||||||
|  |         try: | ||||||
|  |             if self.sub_part.trackable: | ||||||
|  |                 if not self.quantity == int(self.quantity): | ||||||
|  |                     raise ValidationError({ | ||||||
|  |                         "quantity": _("Quantity must be integer value for trackable parts") | ||||||
|  |                     }) | ||||||
|  |         except Part.DoesNotExist: | ||||||
|  |             pass | ||||||
|  |  | ||||||
|         # A part cannot refer to itself in its BOM |         # A part cannot refer to itself in its BOM | ||||||
|         try: |         try: | ||||||
|             if self.sub_part is not None and self.part is not None: |             if self.sub_part is not None and self.part is not None: | ||||||
|   | |||||||
| @@ -62,14 +62,20 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer): | |||||||
|     Serializer for the PartTestTemplate class |     Serializer for the PartTestTemplate class | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|  |     key = serializers.CharField(read_only=True) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = PartTestTemplate |         model = PartTestTemplate | ||||||
|  |  | ||||||
|         fields = [ |         fields = [ | ||||||
|             'pk', |             'pk', | ||||||
|  |             'key', | ||||||
|             'part', |             'part', | ||||||
|             'test_name', |             'test_name', | ||||||
|             'required' |             'description', | ||||||
|  |             'required', | ||||||
|  |             'requires_value', | ||||||
|  |             'requires_attachment', | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -99,9 +105,11 @@ class PartBriefSerializer(InvenTreeModelSerializer): | |||||||
|             'thumbnail', |             'thumbnail', | ||||||
|             'active', |             'active', | ||||||
|             'assembly', |             'assembly', | ||||||
|  |             'is_template', | ||||||
|             'purchaseable', |             'purchaseable', | ||||||
|             'salable', |             'salable', | ||||||
|             'stock', |             'stock', | ||||||
|  |             'trackable', | ||||||
|             'virtual', |             'virtual', | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|   | |||||||
| @@ -33,6 +33,13 @@ | |||||||
|             <td>{{ part.revision }}</td> |             <td>{{ part.revision }}</td> | ||||||
|         </tr> |         </tr> | ||||||
|         {% endif %} |         {% endif %} | ||||||
|  |         {% if part.trackable %} | ||||||
|  |         <tr> | ||||||
|  |             <td><span class='fas fa-hashtag'></span></td> | ||||||
|  |             <td><b>{% trans "Next Serial Number" %}</b></td> | ||||||
|  |             <td>{{ part.getNextSerialNumber }}</td> | ||||||
|  |         </tr> | ||||||
|  |         {% endif %} | ||||||
|         <tr> |         <tr> | ||||||
|             <td><span class='fas fa-info-circle'></span></td> |             <td><span class='fas fa-info-circle'></span></td> | ||||||
|             <td><b>{% trans "Description" %}</b></td> |             <td><b>{% trans "Description" %}</b></td> | ||||||
|   | |||||||
| @@ -6,11 +6,14 @@ | |||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
|  | {% if part.virtual %} | ||||||
|  | <div class='alert alert-info alert-block'> | ||||||
|  |     {% trans "This part is a virtual part" %} | ||||||
|  | </div> | ||||||
|  | {% endif %} | ||||||
| {% if part.is_template %} | {% if part.is_template %} | ||||||
| <div class='alert alert-info alert-block'> | <div class='alert alert-info alert-block'> | ||||||
|     {% trans "This part is a template part." %} |     {% trans "This part is a template part." %} | ||||||
|     <br> |  | ||||||
|     {% trans "It is not a real part, but real parts can be based on this template." %} |  | ||||||
| </div> | </div> | ||||||
| {% endif %} | {% endif %} | ||||||
| {% if part.variant_of %} | {% if part.variant_of %} | ||||||
|   | |||||||
| @@ -13,9 +13,11 @@ | |||||||
|         <a href="{% url 'part-variants' part.id %}">{% trans "Variants" %} <span class='badge'>{{ part.variants.count }}</span></span></a> |         <a href="{% url 'part-variants' part.id %}">{% trans "Variants" %} <span class='badge'>{{ part.variants.count }}</span></span></a> | ||||||
|     </li> |     </li> | ||||||
|     {% endif %} |     {% endif %} | ||||||
|  |     {% if not part.virtual %} | ||||||
|     <li{% ifequal tab 'stock' %} class="active"{% endifequal %}> |     <li{% ifequal tab 'stock' %} class="active"{% endifequal %}> | ||||||
|         <a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal part.total_stock %}</span></a> |         <a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal part.total_stock %}</span></a> | ||||||
|     </li> |     </li> | ||||||
|  |     {% endif %} | ||||||
|     {% if part.component or part.used_in_count > 0 %} |     {% if part.component or part.used_in_count > 0 %} | ||||||
|     <li{% ifequal tab 'allocation' %} class="active"{% endifequal %}> |     <li{% ifequal tab 'allocation' %} class="active"{% endifequal %}> | ||||||
|         <a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal part.allocation_count %}</span></a> |         <a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal part.allocation_count %}</span></a> | ||||||
|   | |||||||
| @@ -44,6 +44,20 @@ class BomItemTest(TestCase): | |||||||
|             item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7) |             item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7) | ||||||
|             item.clean() |             item.clean() | ||||||
|  |  | ||||||
|  |     def test_integer_quantity(self): | ||||||
|  |         """ | ||||||
|  |         Test integer validation for BomItem | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         p = Part.objects.create(name="test", description="d", component=True, trackable=True) | ||||||
|  |  | ||||||
|  |         # Creation of a BOMItem with a non-integer quantity of a trackable Part should fail | ||||||
|  |         with self.assertRaises(django_exceptions.ValidationError): | ||||||
|  |             BomItem.objects.create(part=self.bob, sub_part=p, quantity=21.7) | ||||||
|  |  | ||||||
|  |         # But with an integer quantity, should be fine | ||||||
|  |         BomItem.objects.create(part=self.bob, sub_part=p, quantity=21) | ||||||
|  |  | ||||||
|     def test_overage(self): |     def test_overage(self): | ||||||
|         """ Test that BOM line overages are calculated correctly """ |         """ Test that BOM line overages are calculated correctly """ | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								InvenTree/report/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/report/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										22
									
								
								InvenTree/report/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								InvenTree/report/admin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | # -*- coding: utf-8 -*- | ||||||
|  | from __future__ import unicode_literals | ||||||
|  |  | ||||||
|  | from django.contrib import admin | ||||||
|  |  | ||||||
|  | from .models import ReportTemplate, ReportAsset | ||||||
|  | from .models import TestReport | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportTemplateAdmin(admin.ModelAdmin): | ||||||
|  |  | ||||||
|  |     list_display = ('name', 'description', 'template') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportAssetAdmin(admin.ModelAdmin): | ||||||
|  |  | ||||||
|  |     list_display = ('asset', 'description') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | admin.site.register(ReportTemplate, ReportTemplateAdmin) | ||||||
|  | admin.site.register(TestReport, ReportTemplateAdmin) | ||||||
|  | admin.site.register(ReportAsset, ReportAssetAdmin) | ||||||
							
								
								
									
										5
									
								
								InvenTree/report/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								InvenTree/report/apps.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | from django.apps import AppConfig | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportConfig(AppConfig): | ||||||
|  |     name = 'report' | ||||||
							
								
								
									
										49
									
								
								InvenTree/report/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								InvenTree/report/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | # Generated by Django 3.0.5 on 2020-05-22 11:00 | ||||||
|  |  | ||||||
|  | import django.core.validators | ||||||
|  | from django.db import migrations, models | ||||||
|  | import report.models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     initial = True | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name='ReportAsset', | ||||||
|  |             fields=[ | ||||||
|  |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||||
|  |                 ('asset', models.FileField(help_text='Report asset file', upload_to=report.models.rename_asset)), | ||||||
|  |                 ('description', models.CharField(help_text='Asset file description', max_length=250)), | ||||||
|  |             ], | ||||||
|  |         ), | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name='ReportTemplate', | ||||||
|  |             fields=[ | ||||||
|  |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||||
|  |                 ('name', models.CharField(help_text='Template name', max_length=100, unique=True)), | ||||||
|  |                 ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])), | ||||||
|  |                 ('description', models.CharField(help_text='Report template description', max_length=250)), | ||||||
|  |             ], | ||||||
|  |             options={ | ||||||
|  |                 'abstract': False, | ||||||
|  |             }, | ||||||
|  |         ), | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name='TestReport', | ||||||
|  |             fields=[ | ||||||
|  |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||||
|  |                 ('name', models.CharField(help_text='Template name', max_length=100, unique=True)), | ||||||
|  |                 ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])), | ||||||
|  |                 ('description', models.CharField(help_text='Report template description', max_length=250)), | ||||||
|  |                 ('part_filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validateFilterString])), | ||||||
|  |             ], | ||||||
|  |             options={ | ||||||
|  |                 'abstract': False, | ||||||
|  |             }, | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										0
									
								
								InvenTree/report/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/report/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										269
									
								
								InvenTree/report/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								InvenTree/report/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,269 @@ | |||||||
|  | """ | ||||||
|  | Report template model definitions | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | # -*- coding: utf-8 -*- | ||||||
|  | from __future__ import unicode_literals | ||||||
|  |  | ||||||
|  | import os | ||||||
|  | import sys | ||||||
|  |  | ||||||
|  | from django.db import models | ||||||
|  | from django.conf import settings | ||||||
|  |  | ||||||
|  | from django.core.validators import FileExtensionValidator | ||||||
|  | from django.core.exceptions import ValidationError | ||||||
|  |  | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  |  | ||||||
|  | from part import models as PartModels | ||||||
|  |  | ||||||
|  | try: | ||||||
|  |     from django_weasyprint import WeasyTemplateResponseMixin | ||||||
|  | except OSError as err: | ||||||
|  |     print("OSError: {e}".format(e=err)) | ||||||
|  |     print("You may require some further system packages to be installed.") | ||||||
|  |     sys.exit(1) | ||||||
|  |  | ||||||
|  | # Conditional import if LaTeX templating is enabled | ||||||
|  | if settings.LATEX_ENABLED: | ||||||
|  |     try: | ||||||
|  |         from django_tex.shortcuts import render_to_pdf | ||||||
|  |     except OSError as err: | ||||||
|  |         print("OSError: {e}".format(e=err)) | ||||||
|  |         print("You may not have a working LaTeX toolchain installed?") | ||||||
|  |         sys.exit(1) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def rename_template(instance, filename): | ||||||
|  |  | ||||||
|  |     filename = os.path.basename(filename) | ||||||
|  |  | ||||||
|  |     return os.path.join('report', 'report_template', instance.getSubdir(), filename) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def validateFilterString(value): | ||||||
|  |     """ | ||||||
|  |     Validate that a provided filter string looks like a list of comma-separated key=value pairs | ||||||
|  |  | ||||||
|  |     These should nominally match to a valid database filter based on the model being filtered. | ||||||
|  |  | ||||||
|  |     e.g. "category=6, IPN=12" | ||||||
|  |     e.g. "part__name=widget" | ||||||
|  |  | ||||||
|  |     The ReportTemplate class uses the filter string to work out which items a given report applies to. | ||||||
|  |     For example, an acceptance test report template might only apply to stock items with a given IPN, | ||||||
|  |     so the string could be set to: | ||||||
|  |  | ||||||
|  |     filters = "IPN = ACME0001" | ||||||
|  |  | ||||||
|  |     Returns a map of key:value pairs | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     # Empty results map | ||||||
|  |     results = {} | ||||||
|  |  | ||||||
|  |     value = str(value).strip() | ||||||
|  |  | ||||||
|  |     if not value or len(value) == 0: | ||||||
|  |         return results | ||||||
|  |  | ||||||
|  |     groups = value.split(',') | ||||||
|  |  | ||||||
|  |     for group in groups: | ||||||
|  |         group = group.strip() | ||||||
|  |  | ||||||
|  |         pair = group.split('=') | ||||||
|  |  | ||||||
|  |         if not len(pair) == 2: | ||||||
|  |             raise ValidationError( | ||||||
|  |                 "Invalid group: {g}".format(g=group) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         k, v = pair | ||||||
|  |  | ||||||
|  |         k = k.strip() | ||||||
|  |         v = v.strip() | ||||||
|  |  | ||||||
|  |         if not k or not v: | ||||||
|  |             raise ValidationError( | ||||||
|  |                 "Invalid group: {g}".format(g=group) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         results[k] = v | ||||||
|  |  | ||||||
|  |     return results | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class WeasyprintReportMixin(WeasyTemplateResponseMixin): | ||||||
|  |     """ | ||||||
|  |     Class for rendering a HTML template to a PDF. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     pdf_filename = 'report.pdf' | ||||||
|  |     pdf_attachment = True | ||||||
|  |  | ||||||
|  |     def __init__(self, request, template, **kwargs): | ||||||
|  |  | ||||||
|  |         self.request = request | ||||||
|  |         self.template_name = template | ||||||
|  |         self.pdf_filename = kwargs.get('filename', 'report.pdf') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportTemplateBase(models.Model): | ||||||
|  |     """ | ||||||
|  |     Reporting template model. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return "{n} - {d}".format(n=self.name, d=self.description) | ||||||
|  |  | ||||||
|  |     def getSubdir(self): | ||||||
|  |         return '' | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def extension(self): | ||||||
|  |         return os.path.splitext(self.template.name)[1].lower() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def template_name(self): | ||||||
|  |         return os.path.join('report_template', self.getSubdir(), os.path.basename(self.template.name)) | ||||||
|  |  | ||||||
|  |     def get_context_data(self, request): | ||||||
|  |         """ | ||||||
|  |         Supply context data to the template for rendering | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         return {} | ||||||
|  |  | ||||||
|  |     def render(self, request, **kwargs): | ||||||
|  |         """ | ||||||
|  |         Render the template to a PDF file. | ||||||
|  |  | ||||||
|  |         Supported template formats: | ||||||
|  |             .tex - Uses django-tex plugin to render LaTeX template against an installed LaTeX engine | ||||||
|  |             .html - Uses django-weasyprint plugin to render HTML template against Weasyprint | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         filename = kwargs.get('filename', 'report.pdf') | ||||||
|  |  | ||||||
|  |         context = self.get_context_data(request) | ||||||
|  |  | ||||||
|  |         context['request'] = request | ||||||
|  |  | ||||||
|  |         if self.extension == '.tex': | ||||||
|  |             # Render LaTeX template to PDF | ||||||
|  |             if settings.LATEX_ENABLED: | ||||||
|  |                 return render_to_pdf(request, self.template_name, context, filename=filename) | ||||||
|  |             else: | ||||||
|  |                 return ValidationError("Enable LaTeX support in config.yaml") | ||||||
|  |         elif self.extension in ['.htm', '.html']: | ||||||
|  |             # Render HTML template to PDF | ||||||
|  |             wp = WeasyprintReportMixin(request, self.template_name, **kwargs) | ||||||
|  |             return wp.render_to_response(context, **kwargs) | ||||||
|  |  | ||||||
|  |     name = models.CharField( | ||||||
|  |         blank=False, max_length=100, | ||||||
|  |         help_text=_('Template name'), | ||||||
|  |         unique=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     template = models.FileField( | ||||||
|  |         upload_to=rename_template, | ||||||
|  |         help_text=_("Report template file"), | ||||||
|  |         validators=[FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     description = models.CharField(max_length=250, help_text=_("Report template description")) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         abstract = True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportTemplate(ReportTemplateBase): | ||||||
|  |     """ | ||||||
|  |     A simple reporting template which is used to upload template files, | ||||||
|  |     which can then be used in other concrete template classes. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PartFilterMixin(models.Model): | ||||||
|  |     """ | ||||||
|  |     A model mixin used for matching a report type against a Part object. | ||||||
|  |     Used to assign a report to a given part using custom filters. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         abstract = True | ||||||
|  |  | ||||||
|  |     def matches_part(self, part): | ||||||
|  |         """ | ||||||
|  |         Test if this report matches a given part. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         filters = self.get_part_filters() | ||||||
|  |  | ||||||
|  |         parts = PartModels.Part.objects.filter(**filters) | ||||||
|  |  | ||||||
|  |         parts = parts.filter(pk=part.pk) | ||||||
|  |  | ||||||
|  |         return parts.exists() | ||||||
|  |  | ||||||
|  |     def get_part_filters(self): | ||||||
|  |         """ Return a map of filters to be used for Part filtering """ | ||||||
|  |         return validateFilterString(self.part_filters) | ||||||
|  |  | ||||||
|  |     part_filters = models.CharField( | ||||||
|  |         blank=True, | ||||||
|  |         max_length=250, | ||||||
|  |         help_text=_("Part query filters (comma-separated list of key=value pairs)"), | ||||||
|  |         validators=[validateFilterString] | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestReport(ReportTemplateBase, PartFilterMixin): | ||||||
|  |     """ | ||||||
|  |     Render a TestReport against a StockItem object. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def getSubdir(self): | ||||||
|  |         return 'test' | ||||||
|  |  | ||||||
|  |     # Requires a stock_item object to be given to it before rendering | ||||||
|  |     stock_item = None | ||||||
|  |  | ||||||
|  |     def get_context_data(self, request): | ||||||
|  |         return { | ||||||
|  |             'stock_item': self.stock_item, | ||||||
|  |             'part': self.stock_item.part, | ||||||
|  |             'results': self.stock_item.testResultMap(), | ||||||
|  |             'result_list': self.stock_item.testResultList() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def rename_asset(instance, filename): | ||||||
|  |  | ||||||
|  |     filename = os.path.basename(filename) | ||||||
|  |  | ||||||
|  |     return os.path.join('report', 'assets', filename) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportAsset(models.Model): | ||||||
|  |     """ | ||||||
|  |     Asset file for use in report templates. | ||||||
|  |     For example, an image to use in a header file. | ||||||
|  |     Uploaded asset files appear in MEDIA_ROOT/report/assets, | ||||||
|  |     and can be loaded in a template using the {% report_asset <filename> %} tag. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return os.path.basename(self.asset.name) | ||||||
|  |  | ||||||
|  |     asset = models.FileField( | ||||||
|  |         upload_to=rename_asset, | ||||||
|  |         help_text=_("Report asset file"), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     description = models.CharField(max_length=250, help_text=_("Asset file description")) | ||||||
							
								
								
									
										2
									
								
								InvenTree/report/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								InvenTree/report/tests.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | # -*- coding: utf-8 -*- | ||||||
|  | from __future__ import unicode_literals | ||||||
							
								
								
									
										2
									
								
								InvenTree/report/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								InvenTree/report/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | # -*- coding: utf-8 -*- | ||||||
|  | from __future__ import unicode_literals | ||||||
| @@ -80,21 +80,10 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): | |||||||
|  |  | ||||||
|     def get_serializer(self, *args, **kwargs): |     def get_serializer(self, *args, **kwargs): | ||||||
|  |  | ||||||
|         try: |         kwargs['part_detail'] = True | ||||||
|             kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False)) |         kwargs['location_detail'] = True | ||||||
|         except AttributeError: |         kwargs['supplier_part_detail'] = True | ||||||
|             pass |         kwargs['test_detail'] = True | ||||||
|  |  | ||||||
|         try: |  | ||||||
|             kwargs['location_detail'] = str2bool(self.request.query_params.get('location_detail', False)) |  | ||||||
|         except AttributeError: |  | ||||||
|             pass |  | ||||||
|  |  | ||||||
|         try: |  | ||||||
|             kwargs['supplier_part_detail'] = str2bool(self.request.query_params.get('supplier_detail', False)) |  | ||||||
|         except AttributeError: |  | ||||||
|             pass |  | ||||||
|  |  | ||||||
|         kwargs['context'] = self.get_serializer_context() |         kwargs['context'] = self.get_serializer_context() | ||||||
|  |  | ||||||
|         return self.serializer_class(*args, **kwargs) |         return self.serializer_class(*args, **kwargs) | ||||||
| @@ -499,7 +488,20 @@ class StockList(generics.ListCreateAPIView): | |||||||
|         if serial_number is not None: |         if serial_number is not None: | ||||||
|             queryset = queryset.filter(serial=serial_number) |             queryset = queryset.filter(serial=serial_number) | ||||||
|  |  | ||||||
|         in_stock = self.request.query_params.get('in_stock', None) |         # Filter by range of serial numbers? | ||||||
|  |         serial_number_gte = params.get('serial_gte', None) | ||||||
|  |         serial_number_lte = params.get('serial_lte', None) | ||||||
|  |  | ||||||
|  |         if serial_number_gte is not None or serial_number_lte is not None: | ||||||
|  |             queryset = queryset.exclude(serial=None) | ||||||
|  |  | ||||||
|  |         if serial_number_gte is not None: | ||||||
|  |             queryset = queryset.filter(serial__gte=serial_number_gte) | ||||||
|  |          | ||||||
|  |         if serial_number_lte is not None: | ||||||
|  |             queryset = queryset.filter(serial__lte=serial_number_lte) | ||||||
|  |  | ||||||
|  |         in_stock = params.get('in_stock', None) | ||||||
|  |  | ||||||
|         if in_stock is not None: |         if in_stock is not None: | ||||||
|             in_stock = str2bool(in_stock) |             in_stock = str2bool(in_stock) | ||||||
| @@ -512,7 +514,7 @@ class StockList(generics.ListCreateAPIView): | |||||||
|                 queryset = queryset.exclude(StockItem.IN_STOCK_FILTER) |                 queryset = queryset.exclude(StockItem.IN_STOCK_FILTER) | ||||||
|  |  | ||||||
|         # Filter by 'allocated' patrs? |         # Filter by 'allocated' patrs? | ||||||
|         allocated = self.request.query_params.get('allocated', None) |         allocated = params.get('allocated', None) | ||||||
|  |  | ||||||
|         if allocated is not None: |         if allocated is not None: | ||||||
|             allocated = str2bool(allocated) |             allocated = str2bool(allocated) | ||||||
| @@ -531,8 +533,14 @@ class StockList(generics.ListCreateAPIView): | |||||||
|             active = str2bool(active) |             active = str2bool(active) | ||||||
|             queryset = queryset.filter(part__active=active) |             queryset = queryset.filter(part__active=active) | ||||||
|  |  | ||||||
|  |         # Filter by internal part number | ||||||
|  |         IPN = params.get('IPN', None) | ||||||
|  |  | ||||||
|  |         if IPN: | ||||||
|  |             queryset = queryset.filter(part__IPN=IPN) | ||||||
|  |  | ||||||
|         # Does the client wish to filter by the Part ID? |         # Does the client wish to filter by the Part ID? | ||||||
|         part_id = self.request.query_params.get('part', None) |         part_id = params.get('part', None) | ||||||
|  |  | ||||||
|         if part_id: |         if part_id: | ||||||
|             try: |             try: | ||||||
| @@ -692,17 +700,14 @@ class StockItemTestResultList(generics.ListCreateAPIView): | |||||||
|         'value', |         'value', | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |     ordering = 'date' | ||||||
|  |  | ||||||
|     def get_serializer(self, *args, **kwargs): |     def get_serializer(self, *args, **kwargs): | ||||||
|         try: |         try: | ||||||
|             kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False)) |             kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False)) | ||||||
|         except: |         except: | ||||||
|             pass |             pass | ||||||
|  |  | ||||||
|         try: |  | ||||||
|             kwargs['attachment_detail'] = str2bool(self.request.query_params.get('attachment_detail', False)) |  | ||||||
|         except: |  | ||||||
|             pass |  | ||||||
|  |  | ||||||
|         kwargs['context'] = self.get_serializer_context() |         kwargs['context'] = self.get_serializer_context() | ||||||
|  |  | ||||||
|         return self.serializer_class(*args, **kwargs) |         return self.serializer_class(*args, **kwargs) | ||||||
| @@ -718,23 +723,6 @@ class StockItemTestResultList(generics.ListCreateAPIView): | |||||||
|         # Capture the user information |         # Capture the user information | ||||||
|         test_result = serializer.save() |         test_result = serializer.save() | ||||||
|         test_result.user = self.request.user |         test_result.user = self.request.user | ||||||
|  |  | ||||||
|         # Check if a file has been attached to the request |  | ||||||
|         attachment_file = self.request.FILES.get('attachment', None) |  | ||||||
|  |  | ||||||
|         if attachment_file: |  | ||||||
|             # Create a new attachment associated with the stock item |  | ||||||
|             attachment = StockItemAttachment( |  | ||||||
|                 attachment=attachment_file, |  | ||||||
|                 stock_item=test_result.stock_item, |  | ||||||
|                 user=test_result.user |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|             attachment.save() |  | ||||||
|  |  | ||||||
|             # Link the attachment back to the test result |  | ||||||
|             test_result.attachment = attachment |  | ||||||
|  |  | ||||||
|         test_result.save() |         test_result.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -63,6 +63,18 @@ class EditStockLocationForm(HelperForm): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ConvertStockItemForm(HelperForm): | ||||||
|  |     """ | ||||||
|  |     Form for converting a StockItem to a variant of its current part. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         model = StockItem | ||||||
|  |         fields = [ | ||||||
|  |             'part' | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class CreateStockItemForm(HelperForm): | class CreateStockItemForm(HelperForm): | ||||||
|     """ Form for creating a new StockItem """ |     """ Form for creating a new StockItem """ | ||||||
|  |  | ||||||
| @@ -142,6 +154,34 @@ class SerializeStockForm(HelperForm): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestReportFormatForm(HelperForm): | ||||||
|  |     """ Form for selection a test report template """ | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         model = StockItem | ||||||
|  |         fields = [ | ||||||
|  |             'template', | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |     def __init__(self, stock_item, *args, **kwargs): | ||||||
|  |         self.stock_item = stock_item | ||||||
|  |  | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         self.fields['template'].choices = self.get_template_choices() | ||||||
|  |      | ||||||
|  |     def get_template_choices(self): | ||||||
|  |         """ Available choices """ | ||||||
|  |  | ||||||
|  |         choices = [] | ||||||
|  |  | ||||||
|  |         for report in self.stock_item.part.get_test_report_templates(): | ||||||
|  |             choices.append((report.pk, report)) | ||||||
|  |  | ||||||
|  |         return choices | ||||||
|  |  | ||||||
|  |     template = forms.ChoiceField(label=_('Template'), help_text=_('Select test report template')) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ExportOptionsForm(HelperForm): | class ExportOptionsForm(HelperForm): | ||||||
|     """ Form for selecting stock export options """ |     """ Form for selecting stock export options """ | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										19
									
								
								InvenTree/stock/migrations/0042_auto_20200523_0121.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								InvenTree/stock/migrations/0042_auto_20200523_0121.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | # Generated by Django 3.0.5 on 2020-05-23 01:21 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  | import stock.models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ('stock', '0041_stockitemtestresult_notes'), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name='stockitemtestresult', | ||||||
|  |             name='attachment', | ||||||
|  |             field=models.FileField(blank=True, help_text='Test result attachment', null=True, upload_to=stock.models.rename_stock_item_test_result_attachment, verbose_name='Attachment'), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										20
									
								
								InvenTree/stock/migrations/0043_auto_20200525_0420.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								InvenTree/stock/migrations/0043_auto_20200525_0420.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | # Generated by Django 3.0.5 on 2020-05-25 04:20 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  | import django.db.models.deletion | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ('part', '0042_auto_20200518_0900'), | ||||||
|  |         ('stock', '0042_auto_20200523_0121'), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name='stockitem', | ||||||
|  |             name='part', | ||||||
|  |             field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part', verbose_name='Base Part'), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @@ -331,7 +331,6 @@ class StockItem(MPTTModel): | |||||||
|         verbose_name=_('Base Part'), |         verbose_name=_('Base Part'), | ||||||
|         related_name='stock_items', help_text=_('Base part'), |         related_name='stock_items', help_text=_('Base part'), | ||||||
|         limit_choices_to={ |         limit_choices_to={ | ||||||
|             'is_template': False, |  | ||||||
|             'active': True, |             'active': True, | ||||||
|             'virtual': False |             'virtual': False | ||||||
|         }) |         }) | ||||||
| @@ -647,6 +646,9 @@ class StockItem(MPTTModel): | |||||||
|             # Copy entire transaction history |             # Copy entire transaction history | ||||||
|             new_item.copyHistoryFrom(self) |             new_item.copyHistoryFrom(self) | ||||||
|  |  | ||||||
|  |             # Copy test result history | ||||||
|  |             new_item.copyTestResultsFrom(self) | ||||||
|  |  | ||||||
|             # Create a new stock tracking item |             # Create a new stock tracking item | ||||||
|             new_item.addTransactionNote(_('Add serial number'), user, notes=notes) |             new_item.addTransactionNote(_('Add serial number'), user, notes=notes) | ||||||
|  |  | ||||||
| @@ -655,7 +657,7 @@ class StockItem(MPTTModel): | |||||||
|  |  | ||||||
|     @transaction.atomic |     @transaction.atomic | ||||||
|     def copyHistoryFrom(self, other): |     def copyHistoryFrom(self, other): | ||||||
|         """ Copy stock history from another part """ |         """ Copy stock history from another StockItem """ | ||||||
|  |  | ||||||
|         for item in other.tracking_info.all(): |         for item in other.tracking_info.all(): | ||||||
|              |              | ||||||
| @@ -663,6 +665,17 @@ class StockItem(MPTTModel): | |||||||
|             item.pk = None |             item.pk = None | ||||||
|             item.save() |             item.save() | ||||||
|  |  | ||||||
|  |     @transaction.atomic | ||||||
|  |     def copyTestResultsFrom(self, other, filters={}): | ||||||
|  |         """ Copy all test results from another StockItem """ | ||||||
|  |  | ||||||
|  |         for result in other.test_results.all().filter(**filters): | ||||||
|  |  | ||||||
|  |             # Create a copy of the test result by nulling-out the pk | ||||||
|  |             result.pk = None | ||||||
|  |             result.stock_item = self | ||||||
|  |             result.save() | ||||||
|  |  | ||||||
|     @transaction.atomic |     @transaction.atomic | ||||||
|     def splitStock(self, quantity, location, user): |     def splitStock(self, quantity, location, user): | ||||||
|         """ Split this stock item into two items, in the same location. |         """ Split this stock item into two items, in the same location. | ||||||
| @@ -713,6 +726,9 @@ class StockItem(MPTTModel): | |||||||
|         # Copy the transaction history of this part into the new one |         # Copy the transaction history of this part into the new one | ||||||
|         new_stock.copyHistoryFrom(self) |         new_stock.copyHistoryFrom(self) | ||||||
|  |  | ||||||
|  |         # Copy the test results of this part to the new one | ||||||
|  |         new_stock.copyTestResultsFrom(self) | ||||||
|  |  | ||||||
|         # Add a new tracking item for the new stock item |         # Add a new tracking item for the new stock item | ||||||
|         new_stock.addTransactionNote( |         new_stock.addTransactionNote( | ||||||
|             "Split from existing stock", |             "Split from existing stock", | ||||||
| @@ -963,6 +979,13 @@ class StockItem(MPTTModel): | |||||||
|  |  | ||||||
|         return result_map |         return result_map | ||||||
|  |  | ||||||
|  |     def testResultList(self, **kwargs): | ||||||
|  |         """ | ||||||
|  |         Return a list of test-result objects for this StockItem | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         return self.testResultMap(**kwargs).values() | ||||||
|  |  | ||||||
|     def requiredTestStatus(self): |     def requiredTestStatus(self): | ||||||
|         """ |         """ | ||||||
|         Return the status of the tests required for this StockItem. |         Return the status of the tests required for this StockItem. | ||||||
| @@ -1000,6 +1023,10 @@ class StockItem(MPTTModel): | |||||||
|             'failed': failed, |             'failed': failed, | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def required_test_count(self): | ||||||
|  |         return self.part.getRequiredTests().count() | ||||||
|  |  | ||||||
|     def hasRequiredTests(self): |     def hasRequiredTests(self): | ||||||
|         return self.part.getRequiredTests().count() > 0 |         return self.part.getRequiredTests().count() > 0 | ||||||
|  |  | ||||||
| @@ -1083,6 +1110,11 @@ class StockItemTracking(models.Model): | |||||||
|     # file = models.FileField() |     # file = models.FileField() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def rename_stock_item_test_result_attachment(instance, filename): | ||||||
|  |  | ||||||
|  |     return os.path.join('stock_files', str(instance.stock_item.pk), os.path.basename(filename)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockItemTestResult(models.Model): | class StockItemTestResult(models.Model): | ||||||
|     """ |     """ | ||||||
|     A StockItemTestResult records results of custom tests against individual StockItem objects. |     A StockItemTestResult records results of custom tests against individual StockItem objects. | ||||||
| @@ -1102,19 +1134,41 @@ class StockItemTestResult(models.Model): | |||||||
|         date: Date the test result was recorded |         date: Date the test result was recorded | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|  |     def save(self, *args, **kwargs): | ||||||
|  |  | ||||||
|  |         super().clean() | ||||||
|  |         super().validate_unique() | ||||||
|  |         super().save(*args, **kwargs) | ||||||
|  |  | ||||||
|     def clean(self): |     def clean(self): | ||||||
|  |  | ||||||
|         super().clean() |         super().clean() | ||||||
|  |  | ||||||
|         # If an attachment is linked to this result, the attachment must also point to the item |         # If this test result corresponds to a template, check the requirements of the template | ||||||
|         try: |         key = self.key | ||||||
|             if self.attachment: |  | ||||||
|                 if not self.attachment.stock_item == self.stock_item: |         templates = self.stock_item.part.getTestTemplates() | ||||||
|  |  | ||||||
|  |         for template in templates: | ||||||
|  |             if key == template.key: | ||||||
|  |                  | ||||||
|  |                 if template.requires_value: | ||||||
|  |                     if not self.value: | ||||||
|                         raise ValidationError({ |                         raise ValidationError({ | ||||||
|                         'attachment': _("Test result attachment must be linked to the same StockItem"), |                             "value": _("Value must be provided for this test"), | ||||||
|                         }) |                         }) | ||||||
|         except (StockItem.DoesNotExist, StockItemAttachment.DoesNotExist): |  | ||||||
|             pass |                 if template.requires_attachment: | ||||||
|  |                     if not self.attachment: | ||||||
|  |                         raise ValidationError({ | ||||||
|  |                             "attachment": _("Attachment must be uploaded for this test"), | ||||||
|  |                         }) | ||||||
|  |  | ||||||
|  |                 break | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def key(self): | ||||||
|  |         return helpers.generateTestKey(self.test) | ||||||
|  |  | ||||||
|     stock_item = models.ForeignKey( |     stock_item = models.ForeignKey( | ||||||
|         StockItem, |         StockItem, | ||||||
| @@ -1140,10 +1194,9 @@ class StockItemTestResult(models.Model): | |||||||
|         help_text=_('Test output value') |         help_text=_('Test output value') | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     attachment = models.ForeignKey( |     attachment = models.FileField( | ||||||
|         StockItemAttachment, |         null=True, blank=True, | ||||||
|         on_delete=models.SET_NULL, |         upload_to=rename_stock_item_test_result_attachment, | ||||||
|         blank=True, null=True, |  | ||||||
|         verbose_name=_('Attachment'), |         verbose_name=_('Attachment'), | ||||||
|         help_text=_('Test result attachment'), |         help_text=_('Test result attachment'), | ||||||
|     ) |     ) | ||||||
|   | |||||||
| @@ -108,11 +108,14 @@ class StockItemSerializer(InvenTreeModelSerializer): | |||||||
|     quantity = serializers.FloatField() |     quantity = serializers.FloatField() | ||||||
|     allocated = serializers.FloatField() |     allocated = serializers.FloatField() | ||||||
|  |  | ||||||
|  |     required_tests = serializers.IntegerField(source='required_test_count', read_only=True) | ||||||
|  |  | ||||||
|     def __init__(self, *args, **kwargs): |     def __init__(self, *args, **kwargs): | ||||||
|  |  | ||||||
|         part_detail = kwargs.pop('part_detail', False) |         part_detail = kwargs.pop('part_detail', False) | ||||||
|         location_detail = kwargs.pop('location_detail', False) |         location_detail = kwargs.pop('location_detail', False) | ||||||
|         supplier_part_detail = kwargs.pop('supplier_part_detail', False) |         supplier_part_detail = kwargs.pop('supplier_part_detail', False) | ||||||
|  |         test_detail = kwargs.pop('test_detail', False) | ||||||
|  |  | ||||||
|         super(StockItemSerializer, self).__init__(*args, **kwargs) |         super(StockItemSerializer, self).__init__(*args, **kwargs) | ||||||
|  |  | ||||||
| @@ -125,6 +128,9 @@ class StockItemSerializer(InvenTreeModelSerializer): | |||||||
|         if supplier_part_detail is not True: |         if supplier_part_detail is not True: | ||||||
|             self.fields.pop('supplier_part_detail') |             self.fields.pop('supplier_part_detail') | ||||||
|  |  | ||||||
|  |         if test_detail is not True: | ||||||
|  |             self.fields.pop('required_tests') | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = StockItem |         model = StockItem | ||||||
|         fields = [ |         fields = [ | ||||||
| @@ -141,6 +147,7 @@ class StockItemSerializer(InvenTreeModelSerializer): | |||||||
|             'part_detail', |             'part_detail', | ||||||
|             'pk', |             'pk', | ||||||
|             'quantity', |             'quantity', | ||||||
|  |             'required_tests', | ||||||
|             'sales_order', |             'sales_order', | ||||||
|             'serial', |             'serial', | ||||||
|             'supplier_part', |             'supplier_part', | ||||||
| @@ -222,31 +229,28 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): | |||||||
|     """ Serializer for the StockItemTestResult model """ |     """ Serializer for the StockItemTestResult model """ | ||||||
|  |  | ||||||
|     user_detail = UserSerializerBrief(source='user', read_only=True) |     user_detail = UserSerializerBrief(source='user', read_only=True) | ||||||
|     attachment_detail = StockItemAttachmentSerializer(source='attachment', read_only=True) |  | ||||||
|  |     key = serializers.CharField(read_only=True) | ||||||
|  |  | ||||||
|     def __init__(self, *args, **kwargs): |     def __init__(self, *args, **kwargs): | ||||||
|         user_detail = kwargs.pop('user_detail', False) |         user_detail = kwargs.pop('user_detail', False) | ||||||
|         attachment_detail = kwargs.pop('attachment_detail', False) |  | ||||||
|  |  | ||||||
|         super().__init__(*args, **kwargs) |         super().__init__(*args, **kwargs) | ||||||
|  |  | ||||||
|         if user_detail is not True: |         if user_detail is not True: | ||||||
|             self.fields.pop('user_detail') |             self.fields.pop('user_detail') | ||||||
|  |  | ||||||
|         if attachment_detail is not True: |  | ||||||
|             self.fields.pop('attachment_detail') |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = StockItemTestResult |         model = StockItemTestResult | ||||||
|  |  | ||||||
|         fields = [ |         fields = [ | ||||||
|             'pk', |             'pk', | ||||||
|             'stock_item', |             'stock_item', | ||||||
|  |             'key', | ||||||
|             'test', |             'test', | ||||||
|             'result', |             'result', | ||||||
|             'value', |             'value', | ||||||
|             'attachment', |             'attachment', | ||||||
|             'attachment_detail', |  | ||||||
|             'notes', |             'notes', | ||||||
|             'user', |             'user', | ||||||
|             'user_detail', |             'user_detail', | ||||||
| @@ -255,7 +259,6 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): | |||||||
|  |  | ||||||
|         read_only_fields = [ |         read_only_fields = [ | ||||||
|             'pk', |             'pk', | ||||||
|             'attachment', |  | ||||||
|             'user', |             'user', | ||||||
|             'date', |             'date', | ||||||
|         ] |         ] | ||||||
|   | |||||||
| @@ -93,8 +93,18 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} | |||||||
|         <span class='fas fa-copy'/> |         <span class='fas fa-copy'/> | ||||||
|     </button> |     </button> | ||||||
|     {% endif %} |     {% endif %} | ||||||
|  |     {% if item.part.has_variants %} | ||||||
|  |     <button type='button' class='btn btn-default' id='stock-convert' title="Convert stock to variant"> | ||||||
|  |         <span class='fas fa-screwdriver'/> | ||||||
|  |     </button> | ||||||
|  |     {% endif %} | ||||||
|  |     {% if item.part.has_test_report_templates %} | ||||||
|  |     <button type='button' class='btn btn-default' id='stock-test-report' title='Generate test report'> | ||||||
|  |         <span class='fas fa-tasks'/> | ||||||
|  |     </button> | ||||||
|  |     {% endif %} | ||||||
|     <button type='button' class='btn btn-default' id='stock-edit' title='Edit stock item'> |     <button type='button' class='btn btn-default' id='stock-edit' title='Edit stock item'> | ||||||
|         <span class='fas fa-edit'/> |         <span class='fas fa-edit icon-blue'/> | ||||||
|     </button> |     </button> | ||||||
|     {% if item.can_delete %} |     {% if item.can_delete %} | ||||||
|     <button type='button' class='btn btn-default' id='stock-delete' title='Edit stock item'> |     <button type='button' class='btn btn-default' id='stock-delete' title='Edit stock item'> | ||||||
| @@ -264,6 +274,17 @@ $("#stock-serialize").click(function() { | |||||||
|     ); |     ); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | {% if item.part.has_test_report_templates %} | ||||||
|  | $("#stock-test-report").click(function() { | ||||||
|  |     launchModalForm( | ||||||
|  |         "{% url 'stock-item-test-report-select' item.id %}", | ||||||
|  |         { | ||||||
|  |             follow: true, | ||||||
|  |         } | ||||||
|  |     ); | ||||||
|  | }); | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
| $("#stock-duplicate").click(function() { | $("#stock-duplicate").click(function() { | ||||||
|     launchModalForm( |     launchModalForm( | ||||||
|         "{% url 'stock-item-create' %}", |         "{% url 'stock-item-create' %}", | ||||||
| @@ -308,6 +329,16 @@ function itemAdjust(action) { | |||||||
|     ); |     ); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | {% if item.part.has_variants %} | ||||||
|  | $("#stock-convert").click(function() { | ||||||
|  |     launchModalForm("{% url 'stock-item-convert' item.id %}", | ||||||
|  |         { | ||||||
|  |             reload: true, | ||||||
|  |         } | ||||||
|  |     ); | ||||||
|  | }); | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
| $("#stock-move").click(function() { | $("#stock-move").click(function() { | ||||||
|     itemAdjust("move"); |     itemAdjust("move"); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -13,7 +13,13 @@ | |||||||
| <div id='button-toolbar'> | <div id='button-toolbar'> | ||||||
|     <div class='button-toolbar container-fluid' style="float: right;"> |     <div class='button-toolbar container-fluid' style="float: right;"> | ||||||
|         <div class='btn-group' role='group'> |         <div class='btn-group' role='group'> | ||||||
|  |             {% if user.is_staff %} | ||||||
|  |             <button type='button' class='btn btn-danger' id='delete-test-results'>{% trans "Delete Test Data" %}</button> | ||||||
|  |             {% endif %} | ||||||
|             <button type='button' class='btn btn-success' id='add-test-result'>{% trans "Add Test Data" %}</button> |             <button type='button' class='btn btn-success' id='add-test-result'>{% trans "Add Test Data" %}</button> | ||||||
|  |             {% if item.part.has_test_report_templates %} | ||||||
|  |             <button type='button' class='btn btn-default' id='test-report'>{% trans "Test Report" %} <span class='fas fa-tasks'></span></button> | ||||||
|  |             {% endif %} | ||||||
|         </div> |         </div> | ||||||
|         <div class='filter-list' id='filter-list-stocktests'> |         <div class='filter-list' id='filter-list-stocktests'> | ||||||
|             <!-- Empty div --> |             <!-- Empty div --> | ||||||
| @@ -40,6 +46,28 @@ function reloadTable() { | |||||||
|     //$("#test-result-table").bootstrapTable("refresh"); |     //$("#test-result-table").bootstrapTable("refresh"); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | {% if item.part.has_test_report_templates %} | ||||||
|  | $("#test-report").click(function() { | ||||||
|  |     launchModalForm( | ||||||
|  |         "{% url 'stock-item-test-report-select' item.id %}", | ||||||
|  |         { | ||||||
|  |             follow: true, | ||||||
|  |         } | ||||||
|  |     ); | ||||||
|  | }); | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
|  | {% if user.is_staff %} | ||||||
|  | $("#delete-test-results").click(function() { | ||||||
|  |     launchModalForm( | ||||||
|  |         "{% url 'stock-item-delete-test-data' item.id %}", | ||||||
|  |         { | ||||||
|  |             success: reloadTable, | ||||||
|  |         } | ||||||
|  |     ); | ||||||
|  | }); | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
| $("#add-test-result").click(function() { | $("#add-test-result").click(function() { | ||||||
|     launchModalForm( |     launchModalForm( | ||||||
|         "{% url 'stock-item-test-create' %}", { |         "{% url 'stock-item-test-create' %}", { | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ | |||||||
|             <input class='numberinput' |             <input class='numberinput' | ||||||
|               min='0' |               min='0' | ||||||
|               {% if stock_action == 'take' or stock_action == 'move' %} max='{{ item.quantity }}' {% endif %} |               {% if stock_action == 'take' or stock_action == 'move' %} max='{{ item.quantity }}' {% endif %} | ||||||
|               value='{{ item.new_quantity }}' type='number' name='stock-id-{{ item.id }}' id='stock-id-{{ item.id }}'/> |               value='{% decimal item.new_quantity %}' type='number' name='stock-id-{{ item.id }}' id='stock-id-{{ item.id }}'/> | ||||||
|             {% if item.error %} |             {% if item.error %} | ||||||
|             <br><span class='help-inline'>{{ item.error }}</span> |             <br><span class='help-inline'>{{ item.error }}</span> | ||||||
|             {% endif %} |             {% endif %} | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								InvenTree/stock/templates/stock/stockitem_convert.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								InvenTree/stock/templates/stock/stockitem_convert.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | {% extends "modal_form.html" %} | ||||||
|  | {% load i18n %} | ||||||
|  |  | ||||||
|  | {% block pre_form_content %} | ||||||
|  |  | ||||||
|  | <div class='alert alert-block alert-info'> | ||||||
|  |     <b>{% trans "Convert Stock Item" %}</b><br> | ||||||
|  |     {% trans "This stock item is current an instance of " %}<i>{{ item.part }}</i><br> | ||||||
|  |     {% trans "It can be converted to one of the part variants listed below." %} | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <div class='alert alert-block alert-warning'> | ||||||
|  |     <b>{% trans "Warning" %}</b> | ||||||
|  |     {% trans "This action cannot be easily undone" %} | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
| @@ -458,3 +458,68 @@ class TestResultTest(StockTest): | |||||||
|         ) |         ) | ||||||
|      |      | ||||||
|         self.assertTrue(item.passedAllRequiredTests()) |         self.assertTrue(item.passedAllRequiredTests()) | ||||||
|  |  | ||||||
|  |     def test_duplicate_item_tests(self): | ||||||
|  |  | ||||||
|  |         # Create an example stock item by copying one from the database (because we are lazy) | ||||||
|  |         item = StockItem.objects.get(pk=522) | ||||||
|  |  | ||||||
|  |         item.pk = None | ||||||
|  |         item.serial = None | ||||||
|  |         item.quantity = 50 | ||||||
|  |  | ||||||
|  |         item.save() | ||||||
|  |  | ||||||
|  |         # Do some tests! | ||||||
|  |         StockItemTestResult.objects.create( | ||||||
|  |             stock_item=item, | ||||||
|  |             test="Firmware", | ||||||
|  |             result=True | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         StockItemTestResult.objects.create( | ||||||
|  |             stock_item=item, | ||||||
|  |             test="Paint Color", | ||||||
|  |             result=True, | ||||||
|  |             value="Red" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         StockItemTestResult.objects.create( | ||||||
|  |             stock_item=item, | ||||||
|  |             test="Applied Sticker", | ||||||
|  |             result=False | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self.assertEqual(item.test_results.count(), 3) | ||||||
|  |         self.assertEqual(item.quantity, 50) | ||||||
|  |  | ||||||
|  |         # Split some items out | ||||||
|  |         item2 = item.splitStock(20, None, None) | ||||||
|  |  | ||||||
|  |         self.assertEqual(item.quantity, 30) | ||||||
|  |  | ||||||
|  |         self.assertEqual(item.test_results.count(), 3) | ||||||
|  |         self.assertEqual(item2.test_results.count(), 3) | ||||||
|  |  | ||||||
|  |         StockItemTestResult.objects.create( | ||||||
|  |             stock_item=item2, | ||||||
|  |             test='A new test' | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self.assertEqual(item.test_results.count(), 3) | ||||||
|  |         self.assertEqual(item2.test_results.count(), 4) | ||||||
|  |  | ||||||
|  |         # Test StockItem serialization | ||||||
|  |         item2.serializeStock(1, [100], self.user) | ||||||
|  |  | ||||||
|  |         # Add a test result to the parent *after* serialization | ||||||
|  |         StockItemTestResult.objects.create( | ||||||
|  |             stock_item=item2, | ||||||
|  |             test='abcde' | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self.assertEqual(item2.test_results.count(), 5) | ||||||
|  |  | ||||||
|  |         item3 = StockItem.objects.get(serial=100, part=item2.part) | ||||||
|  |  | ||||||
|  |         self.assertEqual(item3.test_results.count(), 4) | ||||||
|   | |||||||
| @@ -18,12 +18,16 @@ stock_location_detail_urls = [ | |||||||
|  |  | ||||||
| stock_item_detail_urls = [ | stock_item_detail_urls = [ | ||||||
|     url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'), |     url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'), | ||||||
|  |     url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), | ||||||
|     url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'), |     url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'), | ||||||
|     url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), |     url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), | ||||||
|     url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), |     url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), | ||||||
|  |     url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), | ||||||
|  |  | ||||||
|     url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), |     url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), | ||||||
|  |  | ||||||
|  |     url(r'^test-report-select/', views.StockItemTestReportSelect.as_view(), name='stock-item-test-report-select'), | ||||||
|  |  | ||||||
|     url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'), |     url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'), | ||||||
|     url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'), |     url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'), | ||||||
|     url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'), |     url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'), | ||||||
| @@ -52,6 +56,8 @@ stock_urls = [ | |||||||
|  |  | ||||||
|     url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'), |     url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'), | ||||||
|  |  | ||||||
|  |     url(r'^item/test-report-download/', views.StockItemTestReportDownload.as_view(), name='stock-item-test-report-download'), | ||||||
|  |  | ||||||
|     # URLs for StockItem attachments |     # URLs for StockItem attachments | ||||||
|     url(r'^item/attachment/', include([ |     url(r'^item/attachment/', include([ | ||||||
|         url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'), |         url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'), | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ from django.utils.translation import ugettext as _ | |||||||
| from InvenTree.views import AjaxView | from InvenTree.views import AjaxView | ||||||
| from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView | from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView | ||||||
| from InvenTree.views import QRCodeView | from InvenTree.views import QRCodeView | ||||||
|  | from InvenTree.forms import ConfirmForm | ||||||
|  |  | ||||||
| from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats | from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats | ||||||
| from InvenTree.helpers import ExtractSerialNumbers | from InvenTree.helpers import ExtractSerialNumbers | ||||||
| @@ -26,19 +27,12 @@ from datetime import datetime | |||||||
|  |  | ||||||
| from company.models import Company, SupplierPart | from company.models import Company, SupplierPart | ||||||
| from part.models import Part | from part.models import Part | ||||||
|  | from report.models import TestReport | ||||||
| from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult | from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult | ||||||
|  |  | ||||||
| from .admin import StockItemResource | from .admin import StockItemResource | ||||||
|  |  | ||||||
| from .forms import EditStockLocationForm | from . import forms as StockForms | ||||||
| from .forms import CreateStockItemForm |  | ||||||
| from .forms import EditStockItemForm |  | ||||||
| from .forms import AdjustStockForm |  | ||||||
| from .forms import TrackingEntryForm |  | ||||||
| from .forms import SerializeStockForm |  | ||||||
| from .forms import ExportOptionsForm |  | ||||||
| from .forms import EditStockItemAttachmentForm |  | ||||||
| from .forms import EditStockItemTestResultForm |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockIndex(ListView): | class StockIndex(ListView): | ||||||
| @@ -113,7 +107,7 @@ class StockLocationEdit(AjaxUpdateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockLocation |     model = StockLocation | ||||||
|     form_class = EditStockLocationForm |     form_class = StockForms.EditStockLocationForm | ||||||
|     context_object_name = 'location' |     context_object_name = 'location' | ||||||
|     ajax_template_name = 'modal_form.html' |     ajax_template_name = 'modal_form.html' | ||||||
|     ajax_form_title = _('Edit Stock Location') |     ajax_form_title = _('Edit Stock Location') | ||||||
| @@ -157,7 +151,7 @@ class StockItemAttachmentCreate(AjaxCreateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItemAttachment |     model = StockItemAttachment | ||||||
|     form_class = EditStockItemAttachmentForm |     form_class = StockForms.EditStockItemAttachmentForm | ||||||
|     ajax_form_title = _("Add Stock Item Attachment") |     ajax_form_title = _("Add Stock Item Attachment") | ||||||
|     ajax_template_name = "modal_form.html" |     ajax_template_name = "modal_form.html" | ||||||
|  |  | ||||||
| @@ -202,7 +196,7 @@ class StockItemAttachmentEdit(AjaxUpdateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItemAttachment |     model = StockItemAttachment | ||||||
|     form_class = EditStockItemAttachmentForm |     form_class = StockForms.EditStockItemAttachmentForm | ||||||
|     ajax_form_title = _("Edit Stock Item Attachment") |     ajax_form_title = _("Edit Stock Item Attachment") | ||||||
|  |  | ||||||
|     def get_form(self): |     def get_form(self): | ||||||
| @@ -229,13 +223,48 @@ class StockItemAttachmentDelete(AjaxDeleteView): | |||||||
|         } |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class StockItemDeleteTestData(AjaxUpdateView): | ||||||
|  |     """ | ||||||
|  |     View for deleting all test data | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     model = StockItem | ||||||
|  |     form_class = ConfirmForm | ||||||
|  |     ajax_form_title = _("Delete All Test Data") | ||||||
|  |  | ||||||
|  |     def get_form(self): | ||||||
|  |         return ConfirmForm() | ||||||
|  |  | ||||||
|  |     def post(self, request, *args, **kwargs): | ||||||
|  |  | ||||||
|  |         valid = False | ||||||
|  |  | ||||||
|  |         stock_item = StockItem.objects.get(pk=self.kwargs['pk']) | ||||||
|  |         form = self.get_form() | ||||||
|  |          | ||||||
|  |         confirm = str2bool(request.POST.get('confirm', False)) | ||||||
|  |  | ||||||
|  |         if confirm is not True: | ||||||
|  |             form.errors['confirm'] = [_('Confirm test data deletion')] | ||||||
|  |             form.non_field_errors = [_('Check the confirmation box')] | ||||||
|  |         else: | ||||||
|  |             stock_item.test_results.all().delete() | ||||||
|  |             valid = True | ||||||
|  |  | ||||||
|  |         data = { | ||||||
|  |             'form_valid': valid, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return self.renderJsonResponse(request, form, data) | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockItemTestResultCreate(AjaxCreateView): | class StockItemTestResultCreate(AjaxCreateView): | ||||||
|     """ |     """ | ||||||
|     View for adding a new StockItemTestResult |     View for adding a new StockItemTestResult | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItemTestResult |     model = StockItemTestResult | ||||||
|     form_class = EditStockItemTestResultForm |     form_class = StockForms.EditStockItemTestResultForm | ||||||
|     ajax_form_title = _("Add Test Result") |     ajax_form_title = _("Add Test Result") | ||||||
|  |  | ||||||
|     def post_save(self, **kwargs): |     def post_save(self, **kwargs): | ||||||
| @@ -263,17 +292,6 @@ class StockItemTestResultCreate(AjaxCreateView): | |||||||
|         form = super().get_form() |         form = super().get_form() | ||||||
|         form.fields['stock_item'].widget = HiddenInput() |         form.fields['stock_item'].widget = HiddenInput() | ||||||
|  |  | ||||||
|         # Extract the StockItem object |  | ||||||
|         item_id = form['stock_item'].value() |  | ||||||
|  |  | ||||||
|         # Limit the options for the file attachments |  | ||||||
|         try: |  | ||||||
|             stock_item = StockItem.objects.get(pk=item_id) |  | ||||||
|             form.fields['attachment'].queryset = stock_item.attachments.all() |  | ||||||
|         except (ValueError, StockItem.DoesNotExist): |  | ||||||
|             # Hide the attachments field |  | ||||||
|             form.fields['attachment'].widget = HiddenInput() |  | ||||||
|  |  | ||||||
|         return form |         return form | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -283,7 +301,7 @@ class StockItemTestResultEdit(AjaxUpdateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItemTestResult |     model = StockItemTestResult | ||||||
|     form_class = EditStockItemTestResultForm |     form_class = StockForms.EditStockItemTestResultForm | ||||||
|     ajax_form_title = _("Edit Test Result") |     ajax_form_title = _("Edit Test Result") | ||||||
|  |  | ||||||
|     def get_form(self): |     def get_form(self): | ||||||
| @@ -292,8 +310,6 @@ class StockItemTestResultEdit(AjaxUpdateView): | |||||||
|  |  | ||||||
|         form.fields['stock_item'].widget = HiddenInput() |         form.fields['stock_item'].widget = HiddenInput() | ||||||
|          |          | ||||||
|         form.fields['attachment'].queryset = self.object.stock_item.attachments.all() |  | ||||||
|          |  | ||||||
|         return form |         return form | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -307,12 +323,81 @@ class StockItemTestResultDelete(AjaxDeleteView): | |||||||
|     context_object_name = "result" |     context_object_name = "result" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class StockItemTestReportSelect(AjaxView): | ||||||
|  |     """ | ||||||
|  |     View for selecting a TestReport template, | ||||||
|  |     and generating a TestReport as a PDF. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     model = StockItem | ||||||
|  |     ajax_form_title = _("Select Test Report Template") | ||||||
|  |  | ||||||
|  |     def get_form(self): | ||||||
|  |  | ||||||
|  |         stock_item = StockItem.objects.get(pk=self.kwargs['pk']) | ||||||
|  |         return StockForms.TestReportFormatForm(stock_item) | ||||||
|  |  | ||||||
|  |     def post(self, request, *args, **kwargs): | ||||||
|  |  | ||||||
|  |         template_id = request.POST.get('template', None) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             template = TestReport.objects.get(pk=template_id) | ||||||
|  |         except (ValueError, TestReport.DoesNoteExist): | ||||||
|  |             raise ValidationError({'template': _("Select valid template")}) | ||||||
|  |  | ||||||
|  |         stock_item = StockItem.objects.get(pk=self.kwargs['pk']) | ||||||
|  |  | ||||||
|  |         url = reverse('stock-item-test-report-download') | ||||||
|  |  | ||||||
|  |         url += '?stock_item={id}'.format(id=stock_item.pk) | ||||||
|  |         url += '&template={id}'.format(id=template.pk) | ||||||
|  |  | ||||||
|  |         data = { | ||||||
|  |             'form_valid': True, | ||||||
|  |             'url': url, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return self.renderJsonResponse(request, self.get_form(), data=data) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class StockItemTestReportDownload(AjaxView): | ||||||
|  |     """ | ||||||
|  |     Download a TestReport against a StockItem. | ||||||
|  |  | ||||||
|  |     Requires the following arguments to be passed as URL params: | ||||||
|  |  | ||||||
|  |     stock_item - Valid PK of a StockItem object | ||||||
|  |     template - Valid PK of a TestReport template object | ||||||
|  |  | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def get(self, request, *args, **kwargs): | ||||||
|  |  | ||||||
|  |         template = request.GET.get('template', None) | ||||||
|  |         stock_item = request.GET.get('stock_item', None) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             template = TestReport.objects.get(pk=template) | ||||||
|  |         except (ValueError, TestReport.DoesNotExist): | ||||||
|  |             raise ValidationError({'template': 'Invalid template ID'}) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             stock_item = StockItem.objects.get(pk=stock_item) | ||||||
|  |         except (ValueError, StockItem.DoesNotExist): | ||||||
|  |             raise ValidationError({'stock_item': 'Invalid StockItem ID'}) | ||||||
|  |  | ||||||
|  |         template.stock_item = stock_item | ||||||
|  |  | ||||||
|  |         return template.render(request) | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockExportOptions(AjaxView): | class StockExportOptions(AjaxView): | ||||||
|     """ Form for selecting StockExport options """ |     """ Form for selecting StockExport options """ | ||||||
|  |  | ||||||
|     model = StockLocation |     model = StockLocation | ||||||
|     ajax_form_title = _('Stock Export Options') |     ajax_form_title = _('Stock Export Options') | ||||||
|     form_class = ExportOptionsForm |     form_class = StockForms.ExportOptionsForm | ||||||
|  |  | ||||||
|     def post(self, request, *args, **kwargs): |     def post(self, request, *args, **kwargs): | ||||||
|  |  | ||||||
| @@ -455,7 +540,7 @@ class StockAdjust(AjaxView, FormMixin): | |||||||
|  |  | ||||||
|     ajax_template_name = 'stock/stock_adjust.html' |     ajax_template_name = 'stock/stock_adjust.html' | ||||||
|     ajax_form_title = _('Adjust Stock') |     ajax_form_title = _('Adjust Stock') | ||||||
|     form_class = AdjustStockForm |     form_class = StockForms.AdjustStockForm | ||||||
|     stock_items = [] |     stock_items = [] | ||||||
|  |  | ||||||
|     def get_GET_items(self): |     def get_GET_items(self): | ||||||
| @@ -773,7 +858,7 @@ class StockItemEdit(AjaxUpdateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItem |     model = StockItem | ||||||
|     form_class = EditStockItemForm |     form_class = StockForms.EditStockItemForm | ||||||
|     context_object_name = 'item' |     context_object_name = 'item' | ||||||
|     ajax_template_name = 'modal_form.html' |     ajax_template_name = 'modal_form.html' | ||||||
|     ajax_form_title = _('Edit Stock Item') |     ajax_form_title = _('Edit Stock Item') | ||||||
| @@ -802,6 +887,30 @@ class StockItemEdit(AjaxUpdateView): | |||||||
|         return form |         return form | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class StockItemConvert(AjaxUpdateView): | ||||||
|  |     """ | ||||||
|  |     View for 'converting' a StockItem to a variant of its current part. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     model = StockItem | ||||||
|  |     form_class = StockForms.ConvertStockItemForm | ||||||
|  |     ajax_form_title = _('Convert Stock Item') | ||||||
|  |     ajax_template_name = 'stock/stockitem_convert.html' | ||||||
|  |     context_object_name = 'item' | ||||||
|  |  | ||||||
|  |     def get_form(self): | ||||||
|  |         """ | ||||||
|  |         Filter the available parts. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         form = super().get_form() | ||||||
|  |         item = self.get_object() | ||||||
|  |  | ||||||
|  |         form.fields['part'].queryset = item.part.get_all_variants() | ||||||
|  |  | ||||||
|  |         return form | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockLocationCreate(AjaxCreateView): | class StockLocationCreate(AjaxCreateView): | ||||||
|     """ |     """ | ||||||
|     View for creating a new StockLocation |     View for creating a new StockLocation | ||||||
| @@ -809,7 +918,7 @@ class StockLocationCreate(AjaxCreateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockLocation |     model = StockLocation | ||||||
|     form_class = EditStockLocationForm |     form_class = StockForms.EditStockLocationForm | ||||||
|     context_object_name = 'location' |     context_object_name = 'location' | ||||||
|     ajax_template_name = 'modal_form.html' |     ajax_template_name = 'modal_form.html' | ||||||
|     ajax_form_title = _('Create new Stock Location') |     ajax_form_title = _('Create new Stock Location') | ||||||
| @@ -834,7 +943,7 @@ class StockItemSerialize(AjaxUpdateView): | |||||||
|     model = StockItem |     model = StockItem | ||||||
|     ajax_template_name = 'stock/item_serialize.html' |     ajax_template_name = 'stock/item_serialize.html' | ||||||
|     ajax_form_title = _('Serialize Stock') |     ajax_form_title = _('Serialize Stock') | ||||||
|     form_class = SerializeStockForm |     form_class = StockForms.SerializeStockForm | ||||||
|  |  | ||||||
|     def get_form(self): |     def get_form(self): | ||||||
|  |  | ||||||
| @@ -843,7 +952,7 @@ class StockItemSerialize(AjaxUpdateView): | |||||||
|         # Pass the StockItem object through to the form |         # Pass the StockItem object through to the form | ||||||
|         context['item'] = self.get_object() |         context['item'] = self.get_object() | ||||||
|  |  | ||||||
|         form = SerializeStockForm(**context) |         form = StockForms.SerializeStockForm(**context) | ||||||
|  |  | ||||||
|         return form |         return form | ||||||
|  |  | ||||||
| @@ -922,11 +1031,41 @@ class StockItemCreate(AjaxCreateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItem |     model = StockItem | ||||||
|     form_class = CreateStockItemForm |     form_class = StockForms.CreateStockItemForm | ||||||
|     context_object_name = 'item' |     context_object_name = 'item' | ||||||
|     ajax_template_name = 'modal_form.html' |     ajax_template_name = 'modal_form.html' | ||||||
|     ajax_form_title = _('Create new Stock Item') |     ajax_form_title = _('Create new Stock Item') | ||||||
|  |  | ||||||
|  |     def get_part(self, form=None): | ||||||
|  |         """ | ||||||
|  |         Attempt to get the "part" associted with this new stockitem. | ||||||
|  |  | ||||||
|  |         - May be passed to the form as a query parameter (e.g. ?part=<id>) | ||||||
|  |         - May be passed via the form field itself. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         # Try to extract from the URL query | ||||||
|  |         part_id = self.request.GET.get('part', None) | ||||||
|  |  | ||||||
|  |         if part_id: | ||||||
|  |             try: | ||||||
|  |                 part = Part.objects.get(pk=part_id) | ||||||
|  |                 return part | ||||||
|  |             except (Part.DoesNotExist, ValueError): | ||||||
|  |                 pass | ||||||
|  |  | ||||||
|  |         # Try to get from the form | ||||||
|  |         if form: | ||||||
|  |             try: | ||||||
|  |                 part_id = form['part'].value() | ||||||
|  |                 part = Part.objects.get(pk=part_id) | ||||||
|  |                 return part | ||||||
|  |             except (Part.DoesNotExist, ValueError): | ||||||
|  |                 pass | ||||||
|  |  | ||||||
|  |         # Could not extract a part object | ||||||
|  |         return None | ||||||
|  |  | ||||||
|     def get_form(self): |     def get_form(self): | ||||||
|         """ Get form for StockItem creation. |         """ Get form for StockItem creation. | ||||||
|         Overrides the default get_form() method to intelligently limit |         Overrides the default get_form() method to intelligently limit | ||||||
| @@ -935,15 +1074,9 @@ class StockItemCreate(AjaxCreateView): | |||||||
|  |  | ||||||
|         form = super().get_form() |         form = super().get_form() | ||||||
|  |  | ||||||
|         part = None |         part = self.get_part(form=form) | ||||||
|  |  | ||||||
|         # If the user has selected a Part, limit choices for SupplierPart |  | ||||||
|         if form['part'].value(): |  | ||||||
|             part_id = form['part'].value() |  | ||||||
|  |  | ||||||
|             try: |  | ||||||
|                 part = Part.objects.get(id=part_id) |  | ||||||
|  |  | ||||||
|  |         if part is not None: | ||||||
|             sn = part.getNextSerialNumber() |             sn = part.getNextSerialNumber() | ||||||
|             form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn) |             form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn) | ||||||
|  |  | ||||||
| @@ -977,11 +1110,8 @@ class StockItemCreate(AjaxCreateView): | |||||||
|                     # TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate |                     # TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate | ||||||
|                     form.fields['supplier_part'].initial = all_parts[0].id |                     form.fields['supplier_part'].initial = all_parts[0].id | ||||||
|  |  | ||||||
|             except Part.DoesNotExist: |  | ||||||
|                 pass |  | ||||||
|  |  | ||||||
|         # Otherwise if the user has selected a SupplierPart, we know what Part they meant! |         # Otherwise if the user has selected a SupplierPart, we know what Part they meant! | ||||||
|         elif form['supplier_part'].value() is not None: |         if form['supplier_part'].value() is not None: | ||||||
|             pass |             pass | ||||||
|              |              | ||||||
|         return form |         return form | ||||||
| @@ -1004,28 +1134,21 @@ class StockItemCreate(AjaxCreateView): | |||||||
|         else: |         else: | ||||||
|             initials = super(StockItemCreate, self).get_initial().copy() |             initials = super(StockItemCreate, self).get_initial().copy() | ||||||
|  |  | ||||||
|         part_id = self.request.GET.get('part', None) |         part = self.get_part() | ||||||
|  |  | ||||||
|         loc_id = self.request.GET.get('location', None) |         loc_id = self.request.GET.get('location', None) | ||||||
|         sup_part_id = self.request.GET.get('supplier_part', None) |         sup_part_id = self.request.GET.get('supplier_part', None) | ||||||
|  |  | ||||||
|         part = None |  | ||||||
|         location = None |         location = None | ||||||
|         supplier_part = None |         supplier_part = None | ||||||
|  |  | ||||||
|         # Part field has been specified |         if part is not None: | ||||||
|         if part_id: |  | ||||||
|             try: |  | ||||||
|                 part = Part.objects.get(pk=part_id) |  | ||||||
|  |  | ||||||
|             # Check that the supplied part is 'valid' |             # Check that the supplied part is 'valid' | ||||||
|             if not part.is_template and part.active and not part.virtual: |             if not part.is_template and part.active and not part.virtual: | ||||||
|                 initials['part'] = part |                 initials['part'] = part | ||||||
|                 initials['location'] = part.get_default_location() |                 initials['location'] = part.get_default_location() | ||||||
|                 initials['supplier_part'] = part.default_supplier |                 initials['supplier_part'] = part.default_supplier | ||||||
|  |  | ||||||
|             except (ValueError, Part.DoesNotExist): |  | ||||||
|                 pass |  | ||||||
|  |  | ||||||
|         # SupplierPart field has been specified |         # SupplierPart field has been specified | ||||||
|         # It must match the Part, if that has been supplied |         # It must match the Part, if that has been supplied | ||||||
|         if sup_part_id: |         if sup_part_id: | ||||||
| @@ -1229,7 +1352,7 @@ class StockItemTrackingEdit(AjaxUpdateView): | |||||||
|  |  | ||||||
|     model = StockItemTracking |     model = StockItemTracking | ||||||
|     ajax_form_title = _('Edit Stock Tracking Entry') |     ajax_form_title = _('Edit Stock Tracking Entry') | ||||||
|     form_class = TrackingEntryForm |     form_class = StockForms.TrackingEntryForm | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockItemTrackingCreate(AjaxCreateView): | class StockItemTrackingCreate(AjaxCreateView): | ||||||
| @@ -1238,7 +1361,7 @@ class StockItemTrackingCreate(AjaxCreateView): | |||||||
|  |  | ||||||
|     model = StockItemTracking |     model = StockItemTracking | ||||||
|     ajax_form_title = _("Add Stock Tracking Entry") |     ajax_form_title = _("Add Stock Tracking Entry") | ||||||
|     form_class = TrackingEntryForm |     form_class = StockForms.TrackingEntryForm | ||||||
|  |  | ||||||
|     def post(self, request, *args, **kwargs): |     def post(self, request, *args, **kwargs): | ||||||
|  |  | ||||||
|   | |||||||
| @@ -286,6 +286,14 @@ function loadPartTable(table, url, options={}) { | |||||||
|     }); |     }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | function yesNoLabel(value) { | ||||||
|  |     if (value) { | ||||||
|  |         return `<span class='label label-green'>{% trans "YES" %}</span>`; | ||||||
|  |     } else { | ||||||
|  |         return `<span class='label label-yellow'>{% trans "NO" %}</span>`; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
| function loadPartTestTemplateTable(table, options) { | function loadPartTestTemplateTable(table, options) { | ||||||
|     /* |     /* | ||||||
| @@ -332,16 +340,30 @@ function loadPartTestTemplateTable(table, options) { | |||||||
|                 title: "{% trans "Test Name" %}", |                 title: "{% trans "Test Name" %}", | ||||||
|                 sortable: true, |                 sortable: true, | ||||||
|             }, |             }, | ||||||
|  |             { | ||||||
|  |                 field: 'description', | ||||||
|  |                 title: "{% trans "Description" %}", | ||||||
|  |             }, | ||||||
|             { |             { | ||||||
|                 field: 'required', |                 field: 'required', | ||||||
|                 title: "{% trans 'Required' %}", |                 title: "{% trans 'Required' %}", | ||||||
|                 sortable: true, |                 sortable: true, | ||||||
|                 formatter: function(value) { |                 formatter: function(value) { | ||||||
|                     if (value) { |                     return yesNoLabel(value); | ||||||
|                         return `<span class='label label-green'>{% trans "YES" %}</span>`; |  | ||||||
|                     } else { |  | ||||||
|                         return `<span class='label label-yellow'>{% trans "NO" %}</span>`; |  | ||||||
|                 } |                 } | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 field: 'requires_value', | ||||||
|  |                 title: "{% trans "Requires Value" %}", | ||||||
|  |                 formatter: function(value) { | ||||||
|  |                     return yesNoLabel(value); | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 field: 'requires_attachment', | ||||||
|  |                 title: "{% trans "Requires Attachment" %}", | ||||||
|  |                 formatter: function(value) { | ||||||
|  |                     return yesNoLabel(value); | ||||||
|                 } |                 } | ||||||
|             }, |             }, | ||||||
|             { |             { | ||||||
|   | |||||||
| @@ -32,17 +32,6 @@ function noResultBadge() { | |||||||
|     return `<span class='label label-blue float-right'>{% trans "NO RESULT" %}</span>`; |     return `<span class='label label-blue float-right'>{% trans "NO RESULT" %}</span>`; | ||||||
| } | } | ||||||
|  |  | ||||||
| function testKey(test_name) { |  | ||||||
|     // Convert test name to a unique key without any illegal chars |  | ||||||
|  |  | ||||||
|     test_name = test_name.trim().toLowerCase(); |  | ||||||
|     test_name = test_name.replace(' ', ''); |  | ||||||
|  |  | ||||||
|     test_name = test_name.replace(/[^0-9a-z]/gi, ''); |  | ||||||
|  |  | ||||||
|     return test_name; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function loadStockTestResultsTable(table, options) { | function loadStockTestResultsTable(table, options) { | ||||||
|     /* |     /* | ||||||
|      * Load StockItemTestResult table |      * Load StockItemTestResult table | ||||||
| @@ -56,8 +45,8 @@ function loadStockTestResultsTable(table, options) { | |||||||
|             html += `<span class='badge'>${row.user_detail.username}</span>`; |             html += `<span class='badge'>${row.user_detail.username}</span>`; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (row.attachment_detail) { |         if (row.attachment) { | ||||||
|             html += `<a href='${row.attachment_detail.attachment}'><span class='fas fa-file-alt label-right'></span></a>`; |             html += `<a href='${row.attachment}'><span class='fas fa-file-alt label-right'></span></a>`; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return html; |         return html; | ||||||
| @@ -177,14 +166,14 @@ function loadStockTestResultsTable(table, options) { | |||||||
|                             var match = false; |                             var match = false; | ||||||
|                             var override = false; |                             var override = false; | ||||||
|  |  | ||||||
|                             var key = testKey(item.test); |                             var key = item.key; | ||||||
|  |  | ||||||
|                             // Try to associate this result with a test row |                             // Try to associate this result with a test row | ||||||
|                             tableData.forEach(function(row, index) { |                             tableData.forEach(function(row, index) { | ||||||
|                                  |                                  | ||||||
|                                  |                                  | ||||||
|                                 // The result matches the test template row |                                 // The result matches the test template row | ||||||
|                                 if (key == testKey(row.test_name)) { |                                 if (key == row.key) { | ||||||
|                                      |                                      | ||||||
|                                     // Force the names to be the same! |                                     // Force the names to be the same! | ||||||
|                                     item.test_name = row.test_name; |                                     item.test_name = row.test_name; | ||||||
| @@ -348,12 +337,21 @@ function loadStockTable(table, options) { | |||||||
|                 } else { |                 } else { | ||||||
|                     return '-'; |                     return '-'; | ||||||
|                 } |                 } | ||||||
|             } else if (field == 'location__path') { |             } else if (field == 'location_detail.pathstring') { | ||||||
|                 /* Determine how many locations */ |                 /* Determine how many locations */ | ||||||
|                 var locations = []; |                 var locations = []; | ||||||
|  |  | ||||||
|                 data.forEach(function(item) { |                 data.forEach(function(item) { | ||||||
|                     var loc = item.location; |  | ||||||
|  |                     var loc = null; | ||||||
|  |  | ||||||
|  |                     if (item.location_detail) { | ||||||
|  |                         loc = item.location_detail.pathstring; | ||||||
|  |                     } else { | ||||||
|  |                         loc = "{% trans "Undefined location" %}"; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     console.log("Location: " + loc); | ||||||
|  |  | ||||||
|                     if (!locations.includes(loc)) { |                     if (!locations.includes(loc)) { | ||||||
|                         locations.push(loc);  |                         locations.push(loc);  | ||||||
| @@ -364,7 +362,11 @@ function loadStockTable(table, options) { | |||||||
|                     return "In " + locations.length + " locations"; |                     return "In " + locations.length + " locations"; | ||||||
|                 } else { |                 } else { | ||||||
|                     // A single location! |                     // A single location! | ||||||
|                     return renderLink(row.location__path, '/stock/location/' + row.location + '/') |                     if (row.location_detail) { | ||||||
|  |                         return renderLink(row.location_detail.pathstring, `/stock/location/${row.location}/`); | ||||||
|  |                     } else { | ||||||
|  |                         return "<i>{% trans "Undefined location" %}</i>"; | ||||||
|  |                     } | ||||||
|                 } |                 } | ||||||
|             } else if (field == 'notes') { |             } else if (field == 'notes') { | ||||||
|                 var notes = []; |                 var notes = []; | ||||||
|   | |||||||
| @@ -34,6 +34,14 @@ function getAvailableTableFilters(tableKey) { | |||||||
|                 title: '{% trans "Is allocated" %}', |                 title: '{% trans "Is allocated" %}', | ||||||
|                 description: '{% trans "Item has been alloacted" %}', |                 description: '{% trans "Item has been alloacted" %}', | ||||||
|             }, |             }, | ||||||
|  |             serial_gte: { | ||||||
|  |                 title: "{% trans "Serial number GTE" %}", | ||||||
|  |                 description: "{% trans "Serial number greater than or equal to" %}" | ||||||
|  |             }, | ||||||
|  |             serial_lte: { | ||||||
|  |                 title: "{% trans "Serial number LTE" %}", | ||||||
|  |                 description: "{% trans "Serial number less than or equal to" %}", | ||||||
|  |             }, | ||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								Makefile
									
									
									
									
									
								
							| @@ -51,12 +51,12 @@ style: | |||||||
| # Run unit tests | # Run unit tests | ||||||
| test: | test: | ||||||
| 	cd InvenTree && python3 manage.py check | 	cd InvenTree && python3 manage.py check | ||||||
| 	cd InvenTree && python3 manage.py test build common company order part stock  | 	cd InvenTree && python3 manage.py test build common company order part report stock InvenTree | ||||||
|  |  | ||||||
| # Run code coverage | # Run code coverage | ||||||
| coverage: | coverage: | ||||||
| 	cd InvenTree && python3 manage.py check | 	cd InvenTree && python3 manage.py check | ||||||
| 	coverage run InvenTree/manage.py test build common company order part stock InvenTree | 	coverage run InvenTree/manage.py test build common company order part report stock InvenTree | ||||||
| 	coverage html | 	coverage html | ||||||
|  |  | ||||||
| # Install packages required to generate code docs | # Install packages required to generate code docs | ||||||
|   | |||||||
| @@ -33,3 +33,9 @@ For code documentation, refer to the [developer documentation](http://inventree. | |||||||
| ## Contributing | ## Contributing | ||||||
|  |  | ||||||
| Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.github.io/pages/contribute). | Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.github.io/pages/contribute). | ||||||
|  |  | ||||||
|  | ## Donate | ||||||
|  |  | ||||||
|  | If you use InvenTree and find it to be useful, please consider making a donation toward its continued development.  | ||||||
|  |  | ||||||
|  | [Donate via PayPal](https://paypal.me/inventree?locale.x=en_AU) | ||||||
|   | |||||||
| @@ -20,3 +20,5 @@ coverage==4.0.3                 # Unit test coverage | |||||||
| python-coveralls==2.9.1         # Coveralls linking (for Travis) | python-coveralls==2.9.1         # Coveralls linking (for Travis) | ||||||
| rapidfuzz==0.7.6                # Fuzzy string matching | rapidfuzz==0.7.6                # Fuzzy string matching | ||||||
| django-stdimage==5.1.1          # Advanced ImageField management | django-stdimage==5.1.1          # Advanced ImageField management | ||||||
|  | django-tex==1.1.7               # LaTeX PDF export | ||||||
|  | django-weasyprint==1.0.1        # HTML PDF export | ||||||
		Reference in New Issue
	
	Block a user