mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +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 -*- | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.utils.translation import ugettext as _ | ||||
| from django import forms | ||||
| from crispy_forms.helper import FormHelper | ||||
| from crispy_forms.layout import Layout, Field | ||||
| @@ -92,6 +93,20 @@ class HelperForm(forms.ModelForm): | ||||
|         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): | ||||
|     """ Generic deletion form which provides simple user confirmation | ||||
|     """ | ||||
| @@ -99,7 +114,7 @@ class DeleteForm(forms.Form): | ||||
|     confirm_delete = forms.BooleanField( | ||||
|         required=False, | ||||
|         initial=False, | ||||
|         help_text='Confirm item deletion' | ||||
|         help_text=_('Confirm item deletion') | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
| @@ -131,14 +146,14 @@ class SetPasswordForm(HelperForm): | ||||
|                                      required=True, | ||||
|                                      initial='', | ||||
|                                      widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), | ||||
|                                      help_text='Enter new password') | ||||
|                                      help_text=_('Enter new password')) | ||||
|  | ||||
|     confirm_password = forms.CharField(max_length=100, | ||||
|                                        min_length=8, | ||||
|                                        required=True, | ||||
|                                        initial='', | ||||
|                                        widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), | ||||
|                                        help_text='Confirm new password') | ||||
|                                        help_text=_('Confirm new password')) | ||||
|  | ||||
|     class Meta: | ||||
|         model = User | ||||
|   | ||||
| @@ -72,6 +72,27 @@ if DEBUG: | ||||
|         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? | ||||
| sentry_opts = CONFIG.get('sentry', {}) | ||||
|  | ||||
| @@ -106,12 +127,13 @@ INSTALLED_APPS = [ | ||||
|     'django.contrib.staticfiles', | ||||
|  | ||||
|     # InvenTree apps | ||||
|     'common.apps.CommonConfig', | ||||
|     'part.apps.PartConfig', | ||||
|     'stock.apps.StockConfig', | ||||
|     'company.apps.CompanyConfig', | ||||
|     'build.apps.BuildConfig', | ||||
|     'common.apps.CommonConfig', | ||||
|     'company.apps.CompanyConfig', | ||||
|     'order.apps.OrderConfig', | ||||
|     'part.apps.PartConfig', | ||||
|     'report.apps.ReportConfig', | ||||
|     'stock.apps.StockConfig', | ||||
|  | ||||
|     # Third part add-ons | ||||
|     'django_filters',               # Extended filter functionality | ||||
| @@ -126,6 +148,7 @@ INSTALLED_APPS = [ | ||||
|     'mptt',                         # Modified Preorder Tree Traversal | ||||
|     'markdownx',                    # Markdown editing | ||||
|     'markdownify',                  # Markdown template rendering | ||||
|     'django_tex',                   # LaTeX output | ||||
| ] | ||||
|  | ||||
| LOGGING = { | ||||
| @@ -160,7 +183,11 @@ ROOT_URLCONF = 'InvenTree.urls' | ||||
| TEMPLATES = [ | ||||
|     { | ||||
|         '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, | ||||
|         'OPTIONS': { | ||||
|             '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 = { | ||||
| @@ -315,31 +350,22 @@ DATE_INPUT_FORMATS = [ | ||||
|     "%Y-%m-%d", | ||||
| ] | ||||
|  | ||||
| # LaTeX rendering settings (django-tex) | ||||
| LATEX_SETTINGS = CONFIG.get('latex', {}) | ||||
|  | ||||
| # Static files (CSS, JavaScript, Images) | ||||
| # https://docs.djangoproject.com/en/1.10/howto/static-files/ | ||||
| # Is LaTeX rendering enabled? (Off by default) | ||||
| LATEX_ENABLED = LATEX_SETTINGS.get('enabled', False) | ||||
|  | ||||
| # Web URL endpoint for served static files | ||||
| STATIC_URL = '/static/' | ||||
| # Set the latex interpreter in the config.yaml settings file | ||||
| LATEX_INTERPRETER = LATEX_SETTINGS.get('interpreter', 'pdflatex') | ||||
|  | ||||
| # The filesystem location for served static files | ||||
| STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static'))) | ||||
| LATEX_INTERPRETER_OPTIONS = LATEX_SETTINGS.get('options', '') | ||||
|  | ||||
| STATICFILES_DIRS = [ | ||||
|     os.path.join(BASE_DIR, 'InvenTree', 'static'), | ||||
| LATEX_GRAPHICSPATH = [ | ||||
|     # 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_TEMPLATE_PACK = 'bootstrap3' | ||||
|  | ||||
|   | ||||
| @@ -272,8 +272,9 @@ function setupFilterList(tableKey, table, target) { | ||||
|     for (var key in filters) { | ||||
|         var value = getFilterOptionValue(tableKey, key, filters[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 | ||||
| @@ -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. | ||||
|  */ | ||||
|   | ||||
| @@ -166,6 +166,13 @@ class AjaxMixin(object): | ||||
|             except AttributeError: | ||||
|                 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: | ||||
|             context['form'] = form | ||||
|         else: | ||||
|   | ||||
| @@ -73,3 +73,16 @@ log_queries: False | ||||
| sentry: | ||||
|   enabled: False | ||||
|   # 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 = [ | ||||
|             'part', | ||||
|             '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 report import models as ReportModels | ||||
| from build import models as BuildModels | ||||
| from order import models as OrderModels | ||||
| from company.models import SupplierPart | ||||
| @@ -358,6 +359,24 @@ class Part(MPTTModel): | ||||
|         self.category = category | ||||
|         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): | ||||
|         """ Return the web URL for viewing this part """ | ||||
|         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 self.getTestTemplates(required=True) | ||||
|  | ||||
|     def requiredTestCount(self): | ||||
|         return self.getRequiredTests().count() | ||||
|  | ||||
|     @property | ||||
|     def attachment_count(self): | ||||
|         """ Count the number of attachments for this part. | ||||
| @@ -1087,6 +1109,17 @@ class Part(MPTTModel): | ||||
|  | ||||
|         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): | ||||
|     """ Function for storing a file for a PartAttachment | ||||
| @@ -1204,16 +1237,34 @@ class PartTestTemplate(models.Model): | ||||
|  | ||||
|     test_name = models.CharField( | ||||
|         blank=False, max_length=100, | ||||
|         verbose_name=_("Test name"), | ||||
|         verbose_name=_("Test Name"), | ||||
|         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( | ||||
|         default=True, | ||||
|         verbose_name=_("Required"), | ||||
|         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): | ||||
|     """ | ||||
| @@ -1299,6 +1350,11 @@ class BomItem(models.Model): | ||||
|         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): | ||||
|         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 | ||||
|         """ | ||||
|  | ||||
|         # 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 | ||||
|         try: | ||||
|             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 | ||||
|     """ | ||||
|  | ||||
|     key = serializers.CharField(read_only=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = PartTestTemplate | ||||
|  | ||||
|         fields = [ | ||||
|             'pk', | ||||
|             'key', | ||||
|             'part', | ||||
|             'test_name', | ||||
|             'required' | ||||
|             'description', | ||||
|             'required', | ||||
|             'requires_value', | ||||
|             'requires_attachment', | ||||
|         ] | ||||
|  | ||||
|  | ||||
| @@ -99,9 +105,11 @@ class PartBriefSerializer(InvenTreeModelSerializer): | ||||
|             'thumbnail', | ||||
|             'active', | ||||
|             'assembly', | ||||
|             'is_template', | ||||
|             'purchaseable', | ||||
|             'salable', | ||||
|             'stock', | ||||
|             'trackable', | ||||
|             'virtual', | ||||
|         ] | ||||
|  | ||||
|   | ||||
| @@ -33,6 +33,13 @@ | ||||
|             <td>{{ part.revision }}</td> | ||||
|         </tr> | ||||
|         {% 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> | ||||
|             <td><span class='fas fa-info-circle'></span></td> | ||||
|             <td><b>{% trans "Description" %}</b></td> | ||||
|   | ||||
| @@ -6,11 +6,14 @@ | ||||
|  | ||||
| {% 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 %} | ||||
| <div class='alert alert-info alert-block'> | ||||
|     {% 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> | ||||
| {% endif %} | ||||
| {% 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> | ||||
|     </li> | ||||
|     {% endif %} | ||||
|     {% if not part.virtual %} | ||||
|     <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> | ||||
|     </li> | ||||
|     {% endif %} | ||||
|     {% if part.component or part.used_in_count > 0 %} | ||||
|     <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> | ||||
|   | ||||
| @@ -44,6 +44,20 @@ class BomItemTest(TestCase): | ||||
|             item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7) | ||||
|             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): | ||||
|         """ 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): | ||||
|  | ||||
|         try: | ||||
|             kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False)) | ||||
|         except AttributeError: | ||||
|             pass | ||||
|  | ||||
|         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['part_detail'] = True | ||||
|         kwargs['location_detail'] = True | ||||
|         kwargs['supplier_part_detail'] = True | ||||
|         kwargs['test_detail'] = True | ||||
|         kwargs['context'] = self.get_serializer_context() | ||||
|  | ||||
|         return self.serializer_class(*args, **kwargs) | ||||
| @@ -499,7 +488,20 @@ class StockList(generics.ListCreateAPIView): | ||||
|         if serial_number is not None: | ||||
|             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: | ||||
|             in_stock = str2bool(in_stock) | ||||
| @@ -512,7 +514,7 @@ class StockList(generics.ListCreateAPIView): | ||||
|                 queryset = queryset.exclude(StockItem.IN_STOCK_FILTER) | ||||
|  | ||||
|         # Filter by 'allocated' patrs? | ||||
|         allocated = self.request.query_params.get('allocated', None) | ||||
|         allocated = params.get('allocated', None) | ||||
|  | ||||
|         if allocated is not None: | ||||
|             allocated = str2bool(allocated) | ||||
| @@ -531,8 +533,14 @@ class StockList(generics.ListCreateAPIView): | ||||
|             active = str2bool(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? | ||||
|         part_id = self.request.query_params.get('part', None) | ||||
|         part_id = params.get('part', None) | ||||
|  | ||||
|         if part_id: | ||||
|             try: | ||||
| @@ -692,17 +700,14 @@ class StockItemTestResultList(generics.ListCreateAPIView): | ||||
|         'value', | ||||
|     ] | ||||
|  | ||||
|     ordering = 'date' | ||||
|  | ||||
|     def get_serializer(self, *args, **kwargs): | ||||
|         try: | ||||
|             kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False)) | ||||
|         except: | ||||
|             pass | ||||
|  | ||||
|         try: | ||||
|             kwargs['attachment_detail'] = str2bool(self.request.query_params.get('attachment_detail', False)) | ||||
|         except: | ||||
|             pass | ||||
|  | ||||
|         kwargs['context'] = self.get_serializer_context() | ||||
|  | ||||
|         return self.serializer_class(*args, **kwargs) | ||||
| @@ -718,23 +723,6 @@ class StockItemTestResultList(generics.ListCreateAPIView): | ||||
|         # Capture the user information | ||||
|         test_result = serializer.save() | ||||
|         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() | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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): | ||||
|     """ 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): | ||||
|     """ 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'), | ||||
|         related_name='stock_items', help_text=_('Base part'), | ||||
|         limit_choices_to={ | ||||
|             'is_template': False, | ||||
|             'active': True, | ||||
|             'virtual': False | ||||
|         }) | ||||
| @@ -647,6 +646,9 @@ class StockItem(MPTTModel): | ||||
|             # Copy entire transaction history | ||||
|             new_item.copyHistoryFrom(self) | ||||
|  | ||||
|             # Copy test result history | ||||
|             new_item.copyTestResultsFrom(self) | ||||
|  | ||||
|             # Create a new stock tracking item | ||||
|             new_item.addTransactionNote(_('Add serial number'), user, notes=notes) | ||||
|  | ||||
| @@ -655,7 +657,7 @@ class StockItem(MPTTModel): | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def copyHistoryFrom(self, other): | ||||
|         """ Copy stock history from another part """ | ||||
|         """ Copy stock history from another StockItem """ | ||||
|  | ||||
|         for item in other.tracking_info.all(): | ||||
|              | ||||
| @@ -663,6 +665,17 @@ class StockItem(MPTTModel): | ||||
|             item.pk = None | ||||
|             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 | ||||
|     def splitStock(self, quantity, location, user): | ||||
|         """ 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 | ||||
|         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 | ||||
|         new_stock.addTransactionNote( | ||||
|             "Split from existing stock", | ||||
| @@ -963,6 +979,13 @@ class StockItem(MPTTModel): | ||||
|  | ||||
|         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): | ||||
|         """ | ||||
|         Return the status of the tests required for this StockItem. | ||||
| @@ -1000,6 +1023,10 @@ class StockItem(MPTTModel): | ||||
|             'failed': failed, | ||||
|         } | ||||
|  | ||||
|     @property | ||||
|     def required_test_count(self): | ||||
|         return self.part.getRequiredTests().count() | ||||
|  | ||||
|     def hasRequiredTests(self): | ||||
|         return self.part.getRequiredTests().count() > 0 | ||||
|  | ||||
| @@ -1083,6 +1110,11 @@ class StockItemTracking(models.Model): | ||||
|     # 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): | ||||
|     """ | ||||
|     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 | ||||
|     """ | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|  | ||||
|         super().clean() | ||||
|         super().validate_unique() | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|     def clean(self): | ||||
|  | ||||
|         super().clean() | ||||
|  | ||||
|         # If an attachment is linked to this result, the attachment must also point to the item | ||||
|         try: | ||||
|             if self.attachment: | ||||
|                 if not self.attachment.stock_item == self.stock_item: | ||||
|                     raise ValidationError({ | ||||
|                         'attachment': _("Test result attachment must be linked to the same StockItem"), | ||||
|                     }) | ||||
|         except (StockItem.DoesNotExist, StockItemAttachment.DoesNotExist): | ||||
|             pass | ||||
|         # If this test result corresponds to a template, check the requirements of the template | ||||
|         key = self.key | ||||
|  | ||||
|         templates = self.stock_item.part.getTestTemplates() | ||||
|  | ||||
|         for template in templates: | ||||
|             if key == template.key: | ||||
|                  | ||||
|                 if template.requires_value: | ||||
|                     if not self.value: | ||||
|                         raise ValidationError({ | ||||
|                             "value": _("Value must be provided for this test"), | ||||
|                         }) | ||||
|  | ||||
|                 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( | ||||
|         StockItem, | ||||
| @@ -1140,10 +1194,9 @@ class StockItemTestResult(models.Model): | ||||
|         help_text=_('Test output value') | ||||
|     ) | ||||
|  | ||||
|     attachment = models.ForeignKey( | ||||
|         StockItemAttachment, | ||||
|         on_delete=models.SET_NULL, | ||||
|         blank=True, null=True, | ||||
|     attachment = models.FileField( | ||||
|         null=True, blank=True, | ||||
|         upload_to=rename_stock_item_test_result_attachment, | ||||
|         verbose_name=_('Attachment'), | ||||
|         help_text=_('Test result attachment'), | ||||
|     ) | ||||
|   | ||||
| @@ -108,11 +108,14 @@ class StockItemSerializer(InvenTreeModelSerializer): | ||||
|     quantity = serializers.FloatField() | ||||
|     allocated = serializers.FloatField() | ||||
|  | ||||
|     required_tests = serializers.IntegerField(source='required_test_count', read_only=True) | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|  | ||||
|         part_detail = kwargs.pop('part_detail', False) | ||||
|         location_detail = kwargs.pop('location_detail', False) | ||||
|         supplier_part_detail = kwargs.pop('supplier_part_detail', False) | ||||
|         test_detail = kwargs.pop('test_detail', False) | ||||
|  | ||||
|         super(StockItemSerializer, self).__init__(*args, **kwargs) | ||||
|  | ||||
| @@ -125,6 +128,9 @@ class StockItemSerializer(InvenTreeModelSerializer): | ||||
|         if supplier_part_detail is not True: | ||||
|             self.fields.pop('supplier_part_detail') | ||||
|  | ||||
|         if test_detail is not True: | ||||
|             self.fields.pop('required_tests') | ||||
|  | ||||
|     class Meta: | ||||
|         model = StockItem | ||||
|         fields = [ | ||||
| @@ -141,6 +147,7 @@ class StockItemSerializer(InvenTreeModelSerializer): | ||||
|             'part_detail', | ||||
|             'pk', | ||||
|             'quantity', | ||||
|             'required_tests', | ||||
|             'sales_order', | ||||
|             'serial', | ||||
|             'supplier_part', | ||||
| @@ -222,31 +229,28 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): | ||||
|     """ Serializer for the StockItemTestResult model """ | ||||
|  | ||||
|     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): | ||||
|         user_detail = kwargs.pop('user_detail', False) | ||||
|         attachment_detail = kwargs.pop('attachment_detail', False) | ||||
|  | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|         if user_detail is not True: | ||||
|             self.fields.pop('user_detail') | ||||
|  | ||||
|         if attachment_detail is not True: | ||||
|             self.fields.pop('attachment_detail') | ||||
|  | ||||
|     class Meta: | ||||
|         model = StockItemTestResult | ||||
|  | ||||
|         fields = [ | ||||
|             'pk', | ||||
|             'stock_item', | ||||
|             'key', | ||||
|             'test', | ||||
|             'result', | ||||
|             'value', | ||||
|             'attachment', | ||||
|             'attachment_detail', | ||||
|             'notes', | ||||
|             'user', | ||||
|             'user_detail', | ||||
| @@ -255,7 +259,6 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): | ||||
|  | ||||
|         read_only_fields = [ | ||||
|             'pk', | ||||
|             'attachment', | ||||
|             'user', | ||||
|             'date', | ||||
|         ] | ||||
|   | ||||
| @@ -93,8 +93,18 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} | ||||
|         <span class='fas fa-copy'/> | ||||
|     </button> | ||||
|     {% 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'> | ||||
|         <span class='fas fa-edit'/> | ||||
|         <span class='fas fa-edit icon-blue'/> | ||||
|     </button> | ||||
|     {% if item.can_delete %} | ||||
|     <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() { | ||||
|     launchModalForm( | ||||
|         "{% 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() { | ||||
|     itemAdjust("move"); | ||||
| }); | ||||
|   | ||||
| @@ -13,7 +13,13 @@ | ||||
| <div id='button-toolbar'> | ||||
|     <div class='button-toolbar container-fluid' style="float: right;"> | ||||
|         <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> | ||||
|             {% 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 class='filter-list' id='filter-list-stocktests'> | ||||
|             <!-- Empty div --> | ||||
| @@ -40,6 +46,28 @@ function reloadTable() { | ||||
|     //$("#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() { | ||||
|     launchModalForm( | ||||
|         "{% url 'stock-item-test-create' %}", { | ||||
|   | ||||
| @@ -32,7 +32,7 @@ | ||||
|             <input class='numberinput' | ||||
|               min='0' | ||||
|               {% 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 %} | ||||
|             <br><span class='help-inline'>{{ item.error }}</span> | ||||
|             {% 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()) | ||||
|  | ||||
|     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 = [ | ||||
|     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'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), | ||||
|     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'^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'^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'), | ||||
| @@ -52,6 +56,8 @@ stock_urls = [ | ||||
|  | ||||
|     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 | ||||
|     url(r'^item/attachment/', include([ | ||||
|         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 AjaxUpdateView, AjaxDeleteView, AjaxCreateView | ||||
| from InvenTree.views import QRCodeView | ||||
| from InvenTree.forms import ConfirmForm | ||||
|  | ||||
| from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats | ||||
| from InvenTree.helpers import ExtractSerialNumbers | ||||
| @@ -26,19 +27,12 @@ from datetime import datetime | ||||
|  | ||||
| from company.models import Company, SupplierPart | ||||
| from part.models import Part | ||||
| from report.models import TestReport | ||||
| from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult | ||||
|  | ||||
| from .admin import StockItemResource | ||||
|  | ||||
| from .forms import EditStockLocationForm | ||||
| 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 | ||||
| from . import forms as StockForms | ||||
|  | ||||
|  | ||||
| class StockIndex(ListView): | ||||
| @@ -113,7 +107,7 @@ class StockLocationEdit(AjaxUpdateView): | ||||
|     """ | ||||
|  | ||||
|     model = StockLocation | ||||
|     form_class = EditStockLocationForm | ||||
|     form_class = StockForms.EditStockLocationForm | ||||
|     context_object_name = 'location' | ||||
|     ajax_template_name = 'modal_form.html' | ||||
|     ajax_form_title = _('Edit Stock Location') | ||||
| @@ -157,7 +151,7 @@ class StockItemAttachmentCreate(AjaxCreateView): | ||||
|     """ | ||||
|  | ||||
|     model = StockItemAttachment | ||||
|     form_class = EditStockItemAttachmentForm | ||||
|     form_class = StockForms.EditStockItemAttachmentForm | ||||
|     ajax_form_title = _("Add Stock Item Attachment") | ||||
|     ajax_template_name = "modal_form.html" | ||||
|  | ||||
| @@ -202,7 +196,7 @@ class StockItemAttachmentEdit(AjaxUpdateView): | ||||
|     """ | ||||
|  | ||||
|     model = StockItemAttachment | ||||
|     form_class = EditStockItemAttachmentForm | ||||
|     form_class = StockForms.EditStockItemAttachmentForm | ||||
|     ajax_form_title = _("Edit Stock Item Attachment") | ||||
|  | ||||
|     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): | ||||
|     """ | ||||
|     View for adding a new StockItemTestResult | ||||
|     """ | ||||
|  | ||||
|     model = StockItemTestResult | ||||
|     form_class = EditStockItemTestResultForm | ||||
|     form_class = StockForms.EditStockItemTestResultForm | ||||
|     ajax_form_title = _("Add Test Result") | ||||
|  | ||||
|     def post_save(self, **kwargs): | ||||
| @@ -263,17 +292,6 @@ class StockItemTestResultCreate(AjaxCreateView): | ||||
|         form = super().get_form() | ||||
|         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 | ||||
|  | ||||
|  | ||||
| @@ -283,7 +301,7 @@ class StockItemTestResultEdit(AjaxUpdateView): | ||||
|     """ | ||||
|  | ||||
|     model = StockItemTestResult | ||||
|     form_class = EditStockItemTestResultForm | ||||
|     form_class = StockForms.EditStockItemTestResultForm | ||||
|     ajax_form_title = _("Edit Test Result") | ||||
|  | ||||
|     def get_form(self): | ||||
| @@ -292,8 +310,6 @@ class StockItemTestResultEdit(AjaxUpdateView): | ||||
|  | ||||
|         form.fields['stock_item'].widget = HiddenInput() | ||||
|          | ||||
|         form.fields['attachment'].queryset = self.object.stock_item.attachments.all() | ||||
|          | ||||
|         return form | ||||
|  | ||||
|  | ||||
| @@ -307,12 +323,81 @@ class StockItemTestResultDelete(AjaxDeleteView): | ||||
|     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): | ||||
|     """ Form for selecting StockExport options """ | ||||
|  | ||||
|     model = StockLocation | ||||
|     ajax_form_title = _('Stock Export Options') | ||||
|     form_class = ExportOptionsForm | ||||
|     form_class = StockForms.ExportOptionsForm | ||||
|  | ||||
|     def post(self, request, *args, **kwargs): | ||||
|  | ||||
| @@ -455,7 +540,7 @@ class StockAdjust(AjaxView, FormMixin): | ||||
|  | ||||
|     ajax_template_name = 'stock/stock_adjust.html' | ||||
|     ajax_form_title = _('Adjust Stock') | ||||
|     form_class = AdjustStockForm | ||||
|     form_class = StockForms.AdjustStockForm | ||||
|     stock_items = [] | ||||
|  | ||||
|     def get_GET_items(self): | ||||
| @@ -773,7 +858,7 @@ class StockItemEdit(AjaxUpdateView): | ||||
|     """ | ||||
|  | ||||
|     model = StockItem | ||||
|     form_class = EditStockItemForm | ||||
|     form_class = StockForms.EditStockItemForm | ||||
|     context_object_name = 'item' | ||||
|     ajax_template_name = 'modal_form.html' | ||||
|     ajax_form_title = _('Edit Stock Item') | ||||
| @@ -802,6 +887,30 @@ class StockItemEdit(AjaxUpdateView): | ||||
|         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): | ||||
|     """ | ||||
|     View for creating a new StockLocation | ||||
| @@ -809,7 +918,7 @@ class StockLocationCreate(AjaxCreateView): | ||||
|     """ | ||||
|  | ||||
|     model = StockLocation | ||||
|     form_class = EditStockLocationForm | ||||
|     form_class = StockForms.EditStockLocationForm | ||||
|     context_object_name = 'location' | ||||
|     ajax_template_name = 'modal_form.html' | ||||
|     ajax_form_title = _('Create new Stock Location') | ||||
| @@ -834,7 +943,7 @@ class StockItemSerialize(AjaxUpdateView): | ||||
|     model = StockItem | ||||
|     ajax_template_name = 'stock/item_serialize.html' | ||||
|     ajax_form_title = _('Serialize Stock') | ||||
|     form_class = SerializeStockForm | ||||
|     form_class = StockForms.SerializeStockForm | ||||
|  | ||||
|     def get_form(self): | ||||
|  | ||||
| @@ -843,7 +952,7 @@ class StockItemSerialize(AjaxUpdateView): | ||||
|         # Pass the StockItem object through to the form | ||||
|         context['item'] = self.get_object() | ||||
|  | ||||
|         form = SerializeStockForm(**context) | ||||
|         form = StockForms.SerializeStockForm(**context) | ||||
|  | ||||
|         return form | ||||
|  | ||||
| @@ -922,11 +1031,41 @@ class StockItemCreate(AjaxCreateView): | ||||
|     """ | ||||
|  | ||||
|     model = StockItem | ||||
|     form_class = CreateStockItemForm | ||||
|     form_class = StockForms.CreateStockItemForm | ||||
|     context_object_name = 'item' | ||||
|     ajax_template_name = 'modal_form.html' | ||||
|     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): | ||||
|         """ Get form for StockItem creation. | ||||
|         Overrides the default get_form() method to intelligently limit | ||||
| @@ -935,53 +1074,44 @@ class StockItemCreate(AjaxCreateView): | ||||
|  | ||||
|         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() | ||||
|         if part is not None: | ||||
|             sn = part.getNextSerialNumber() | ||||
|             form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn) | ||||
|  | ||||
|             try: | ||||
|                 part = Part.objects.get(id=part_id) | ||||
|             form.rebuild_layout() | ||||
|  | ||||
|                 sn = part.getNextSerialNumber() | ||||
|                 form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn) | ||||
|             # Hide the 'part' field (as a valid part is selected) | ||||
|             form.fields['part'].widget = HiddenInput() | ||||
|  | ||||
|                 form.rebuild_layout() | ||||
|             # trackable parts get special consideration | ||||
|             if part.trackable: | ||||
|                 form.fields['delete_on_deplete'].widget = HiddenInput() | ||||
|                 form.fields['delete_on_deplete'].initial = False | ||||
|             else: | ||||
|                 form.fields.pop('serial_numbers') | ||||
|  | ||||
|                 # Hide the 'part' field (as a valid part is selected) | ||||
|                 form.fields['part'].widget = HiddenInput() | ||||
|             # If the part is NOT purchaseable, hide the supplier_part field | ||||
|             if not part.purchaseable: | ||||
|                 form.fields['supplier_part'].widget = HiddenInput() | ||||
|             else: | ||||
|                 # Pre-select the allowable SupplierPart options | ||||
|                 parts = form.fields['supplier_part'].queryset | ||||
|                 parts = parts.filter(part=part.id) | ||||
|  | ||||
|                 # trackable parts get special consideration | ||||
|                 if part.trackable: | ||||
|                     form.fields['delete_on_deplete'].widget = HiddenInput() | ||||
|                     form.fields['delete_on_deplete'].initial = False | ||||
|                 else: | ||||
|                     form.fields.pop('serial_numbers') | ||||
|                 form.fields['supplier_part'].queryset = parts | ||||
|  | ||||
|                 # If the part is NOT purchaseable, hide the supplier_part field | ||||
|                 if not part.purchaseable: | ||||
|                     form.fields['supplier_part'].widget = HiddenInput() | ||||
|                 else: | ||||
|                     # Pre-select the allowable SupplierPart options | ||||
|                     parts = form.fields['supplier_part'].queryset | ||||
|                     parts = parts.filter(part=part.id) | ||||
|                 # If there is one (and only one) supplier part available, pre-select it | ||||
|                 all_parts = parts.all() | ||||
|  | ||||
|                     form.fields['supplier_part'].queryset = parts | ||||
|                 if len(all_parts) == 1: | ||||
|  | ||||
|                     # If there is one (and only one) supplier part available, pre-select it | ||||
|                     all_parts = parts.all() | ||||
|  | ||||
|                     if len(all_parts) == 1: | ||||
|  | ||||
|                         # TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate | ||||
|                         form.fields['supplier_part'].initial = all_parts[0].id | ||||
|  | ||||
|             except Part.DoesNotExist: | ||||
|                 pass | ||||
|                     # TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate | ||||
|                     form.fields['supplier_part'].initial = all_parts[0].id | ||||
|  | ||||
|         # 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 | ||||
|              | ||||
|         return form | ||||
| @@ -1004,27 +1134,20 @@ class StockItemCreate(AjaxCreateView): | ||||
|         else: | ||||
|             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) | ||||
|         sup_part_id = self.request.GET.get('supplier_part', None) | ||||
|  | ||||
|         part = None | ||||
|         location = None | ||||
|         supplier_part = None | ||||
|  | ||||
|         # Part field has been specified | ||||
|         if part_id: | ||||
|             try: | ||||
|                 part = Part.objects.get(pk=part_id) | ||||
|  | ||||
|                 # Check that the supplied part is 'valid' | ||||
|                 if not part.is_template and part.active and not part.virtual: | ||||
|                     initials['part'] = part | ||||
|                     initials['location'] = part.get_default_location() | ||||
|                     initials['supplier_part'] = part.default_supplier | ||||
|  | ||||
|             except (ValueError, Part.DoesNotExist): | ||||
|                 pass | ||||
|         if part is not None: | ||||
|             # Check that the supplied part is 'valid' | ||||
|             if not part.is_template and part.active and not part.virtual: | ||||
|                 initials['part'] = part | ||||
|                 initials['location'] = part.get_default_location() | ||||
|                 initials['supplier_part'] = part.default_supplier | ||||
|  | ||||
|         # SupplierPart field has been specified | ||||
|         # It must match the Part, if that has been supplied | ||||
| @@ -1229,7 +1352,7 @@ class StockItemTrackingEdit(AjaxUpdateView): | ||||
|  | ||||
|     model = StockItemTracking | ||||
|     ajax_form_title = _('Edit Stock Tracking Entry') | ||||
|     form_class = TrackingEntryForm | ||||
|     form_class = StockForms.TrackingEntryForm | ||||
|  | ||||
|  | ||||
| class StockItemTrackingCreate(AjaxCreateView): | ||||
| @@ -1238,7 +1361,7 @@ class StockItemTrackingCreate(AjaxCreateView): | ||||
|  | ||||
|     model = StockItemTracking | ||||
|     ajax_form_title = _("Add Stock Tracking Entry") | ||||
|     form_class = TrackingEntryForm | ||||
|     form_class = StockForms.TrackingEntryForm | ||||
|  | ||||
|     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) { | ||||
|     /* | ||||
| @@ -332,16 +340,30 @@ function loadPartTestTemplateTable(table, options) { | ||||
|                 title: "{% trans "Test Name" %}", | ||||
|                 sortable: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'description', | ||||
|                 title: "{% trans "Description" %}", | ||||
|             }, | ||||
|             { | ||||
|                 field: 'required', | ||||
|                 title: "{% trans 'Required' %}", | ||||
|                 sortable: true, | ||||
|                 formatter: function(value) { | ||||
|                     if (value) { | ||||
|                         return `<span class='label label-green'>{% trans "YES" %}</span>`; | ||||
|                     } else { | ||||
|                         return `<span class='label label-yellow'>{% trans "NO" %}</span>`; | ||||
|                     } | ||||
|                     return yesNoLabel(value); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 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>`; | ||||
| } | ||||
|  | ||||
| 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) { | ||||
|     /* | ||||
|      * Load StockItemTestResult table | ||||
| @@ -56,8 +45,8 @@ function loadStockTestResultsTable(table, options) { | ||||
|             html += `<span class='badge'>${row.user_detail.username}</span>`; | ||||
|         } | ||||
|  | ||||
|         if (row.attachment_detail) { | ||||
|             html += `<a href='${row.attachment_detail.attachment}'><span class='fas fa-file-alt label-right'></span></a>`; | ||||
|         if (row.attachment) { | ||||
|             html += `<a href='${row.attachment}'><span class='fas fa-file-alt label-right'></span></a>`; | ||||
|         } | ||||
|  | ||||
|         return html; | ||||
| @@ -177,14 +166,14 @@ function loadStockTestResultsTable(table, options) { | ||||
|                             var match = false; | ||||
|                             var override = false; | ||||
|  | ||||
|                             var key = testKey(item.test); | ||||
|                             var key = item.key; | ||||
|  | ||||
|                             // Try to associate this result with a test row | ||||
|                             tableData.forEach(function(row, index) { | ||||
|                                  | ||||
|                                  | ||||
|                                 // The result matches the test template row | ||||
|                                 if (key == testKey(row.test_name)) { | ||||
|                                 if (key == row.key) { | ||||
|                                      | ||||
|                                     // Force the names to be the same! | ||||
|                                     item.test_name = row.test_name; | ||||
| @@ -348,12 +337,21 @@ function loadStockTable(table, options) { | ||||
|                 } else { | ||||
|                     return '-'; | ||||
|                 } | ||||
|             } else if (field == 'location__path') { | ||||
|             } else if (field == 'location_detail.pathstring') { | ||||
|                 /* Determine how many locations */ | ||||
|                 var locations = []; | ||||
|  | ||||
|                 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)) { | ||||
|                         locations.push(loc);  | ||||
| @@ -364,7 +362,11 @@ function loadStockTable(table, options) { | ||||
|                     return "In " + locations.length + " locations"; | ||||
|                 } else { | ||||
|                     // 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') { | ||||
|                 var notes = []; | ||||
|   | ||||
| @@ -34,6 +34,14 @@ function getAvailableTableFilters(tableKey) { | ||||
|                 title: '{% trans "Is allocated" %}', | ||||
|                 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 | ||||
| test: | ||||
| 	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 | ||||
| coverage: | ||||
| 	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 | ||||
|  | ||||
| # Install packages required to generate code docs | ||||
|   | ||||
| @@ -33,3 +33,9 @@ For code documentation, refer to the [developer documentation](http://inventree. | ||||
| ## 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). | ||||
|  | ||||
| ## 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) | ||||
| rapidfuzz==0.7.6                # Fuzzy string matching | ||||
| 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