mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	Merge pull request #824 from SchrodingersGat/reporting-app
Reporting app
This commit is contained in:
		| @@ -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,19 @@ 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/ | ||||
| # Set the latex interpreter in the config.yaml settings file | ||||
| LATEX_INTERPRETER = latex_settings.get('interpreter', 'pdflatex') | ||||
|  | ||||
| # Web URL endpoint for served static files | ||||
| STATIC_URL = '/static/' | ||||
| LATEX_INTERPRETER_OPTIONS = latex_settings.get('options', '') | ||||
|  | ||||
| # 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'), | ||||
| 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' | ||||
|  | ||||
|   | ||||
| @@ -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,14 @@ 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/ | ||||
| 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 | ||||
|   interpreter: pdflatex  | ||||
|   # Extra options to pass through to the LaTeX interpreter | ||||
|   options: '' | ||||
| @@ -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}) | ||||
|   | ||||
							
								
								
									
										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
									
								
							
							
								
								
									
										250
									
								
								InvenTree/report/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										250
									
								
								InvenTree/report/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,250 @@ | ||||
| """ | ||||
| Report template model definitions | ||||
| """ | ||||
|  | ||||
| # -*- coding: utf-8 -*- | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| import os | ||||
|  | ||||
| from django.db import models | ||||
| 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 | ||||
|  | ||||
| from django_tex.shortcuts import render_to_pdf | ||||
| from django_weasyprint import WeasyTemplateResponseMixin | ||||
|  | ||||
|  | ||||
| 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 | ||||
|             return render_to_pdf(request, self.template_name, context, filename=filename) | ||||
|         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 | ||||
| @@ -142,6 +142,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 """ | ||||
|  | ||||
|   | ||||
| @@ -963,6 +963,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. | ||||
|   | ||||
| @@ -93,8 +93,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} | ||||
|         <span class='fas fa-copy'/> | ||||
|     </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 +269,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' %}", | ||||
|   | ||||
| @@ -25,6 +25,8 @@ stock_item_detail_urls = [ | ||||
|  | ||||
|     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'), | ||||
| @@ -53,6 +55,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'), | ||||
|   | ||||
| @@ -27,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): | ||||
| @@ -114,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') | ||||
| @@ -158,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" | ||||
|  | ||||
| @@ -203,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): | ||||
| @@ -271,7 +264,7 @@ class StockItemTestResultCreate(AjaxCreateView): | ||||
|     """ | ||||
|  | ||||
|     model = StockItemTestResult | ||||
|     form_class = EditStockItemTestResultForm | ||||
|     form_class = StockForms.EditStockItemTestResultForm | ||||
|     ajax_form_title = _("Add Test Result") | ||||
|  | ||||
|     def post_save(self, **kwargs): | ||||
| @@ -319,7 +312,7 @@ class StockItemTestResultEdit(AjaxUpdateView): | ||||
|     """ | ||||
|  | ||||
|     model = StockItemTestResult | ||||
|     form_class = EditStockItemTestResultForm | ||||
|     form_class = StockForms.EditStockItemTestResultForm | ||||
|     ajax_form_title = _("Edit Test Result") | ||||
|  | ||||
|     def get_form(self): | ||||
| @@ -343,12 +336,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): | ||||
|  | ||||
| @@ -491,7 +553,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): | ||||
| @@ -809,7 +871,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') | ||||
| @@ -845,7 +907,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') | ||||
| @@ -870,7 +932,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): | ||||
|  | ||||
| @@ -879,7 +941,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 | ||||
|  | ||||
| @@ -958,7 +1020,7 @@ 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') | ||||
| @@ -1265,7 +1327,7 @@ class StockItemTrackingEdit(AjaxUpdateView): | ||||
|  | ||||
|     model = StockItemTracking | ||||
|     ajax_form_title = _('Edit Stock Tracking Entry') | ||||
|     form_class = TrackingEntryForm | ||||
|     form_class = StockForms.TrackingEntryForm | ||||
|  | ||||
|  | ||||
| class StockItemTrackingCreate(AjaxCreateView): | ||||
| @@ -1274,7 +1336,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): | ||||
|  | ||||
|   | ||||
							
								
								
									
										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 | ||||
|   | ||||
| @@ -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