mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +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', |         format='%(asctime)s %(levelname)s %(message)s', | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  | # Web URL endpoint for served static files | ||||||
|  | STATIC_URL = '/static/' | ||||||
|  |  | ||||||
|  | # The filesystem location for served static files | ||||||
|  | STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static'))) | ||||||
|  |  | ||||||
|  | STATICFILES_DIRS = [ | ||||||
|  |     os.path.join(BASE_DIR, 'InvenTree', 'static'), | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | # Web URL endpoint for served media files | ||||||
|  | MEDIA_URL = '/media/' | ||||||
|  |  | ||||||
|  | # The filesystem location for served static files | ||||||
|  | MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media'))) | ||||||
|  |  | ||||||
|  | if DEBUG: | ||||||
|  |     print("InvenTree running in DEBUG mode") | ||||||
|  |     print("MEDIA_ROOT:", MEDIA_ROOT) | ||||||
|  |     print("STATIC_ROOT:", STATIC_ROOT) | ||||||
|  |  | ||||||
| # Does the user wish to use the sentry.io integration? | # Does the user wish to use the sentry.io integration? | ||||||
| sentry_opts = CONFIG.get('sentry', {}) | sentry_opts = CONFIG.get('sentry', {}) | ||||||
|  |  | ||||||
| @@ -106,12 +127,13 @@ INSTALLED_APPS = [ | |||||||
|     'django.contrib.staticfiles', |     'django.contrib.staticfiles', | ||||||
|  |  | ||||||
|     # InvenTree apps |     # InvenTree apps | ||||||
|     'common.apps.CommonConfig', |  | ||||||
|     'part.apps.PartConfig', |  | ||||||
|     'stock.apps.StockConfig', |  | ||||||
|     'company.apps.CompanyConfig', |  | ||||||
|     'build.apps.BuildConfig', |     'build.apps.BuildConfig', | ||||||
|  |     'common.apps.CommonConfig', | ||||||
|  |     'company.apps.CompanyConfig', | ||||||
|     'order.apps.OrderConfig', |     'order.apps.OrderConfig', | ||||||
|  |     'part.apps.PartConfig', | ||||||
|  |     'report.apps.ReportConfig', | ||||||
|  |     'stock.apps.StockConfig', | ||||||
|  |  | ||||||
|     # Third part add-ons |     # Third part add-ons | ||||||
|     'django_filters',               # Extended filter functionality |     'django_filters',               # Extended filter functionality | ||||||
| @@ -126,6 +148,7 @@ INSTALLED_APPS = [ | |||||||
|     'mptt',                         # Modified Preorder Tree Traversal |     'mptt',                         # Modified Preorder Tree Traversal | ||||||
|     'markdownx',                    # Markdown editing |     'markdownx',                    # Markdown editing | ||||||
|     'markdownify',                  # Markdown template rendering |     'markdownify',                  # Markdown template rendering | ||||||
|  |     'django_tex',                   # LaTeX output | ||||||
| ] | ] | ||||||
|  |  | ||||||
| LOGGING = { | LOGGING = { | ||||||
| @@ -160,7 +183,11 @@ ROOT_URLCONF = 'InvenTree.urls' | |||||||
| TEMPLATES = [ | TEMPLATES = [ | ||||||
|     { |     { | ||||||
|         'BACKEND': 'django.template.backends.django.DjangoTemplates', |         'BACKEND': 'django.template.backends.django.DjangoTemplates', | ||||||
|         'DIRS': [os.path.join(BASE_DIR, 'templates')], |         'DIRS': [ | ||||||
|  |             os.path.join(BASE_DIR, 'templates'), | ||||||
|  |             # Allow templates in the reporting directory to be accessed | ||||||
|  |             os.path.join(MEDIA_ROOT, 'report'), | ||||||
|  |         ], | ||||||
|         'APP_DIRS': True, |         'APP_DIRS': True, | ||||||
|         'OPTIONS': { |         'OPTIONS': { | ||||||
|             'context_processors': [ |             'context_processors': [ | ||||||
| @@ -173,6 +200,14 @@ TEMPLATES = [ | |||||||
|             ], |             ], | ||||||
|         }, |         }, | ||||||
|     }, |     }, | ||||||
|  |     # Backend for LaTeX report rendering | ||||||
|  |     { | ||||||
|  |         'NAME': 'tex', | ||||||
|  |         'BACKEND': 'django_tex.engine.TeXEngine', | ||||||
|  |         'DIRS': [ | ||||||
|  |             os.path.join(MEDIA_ROOT, 'report'), | ||||||
|  |         ] | ||||||
|  |     }, | ||||||
| ] | ] | ||||||
|  |  | ||||||
| REST_FRAMEWORK = { | REST_FRAMEWORK = { | ||||||
| @@ -315,31 +350,19 @@ DATE_INPUT_FORMATS = [ | |||||||
|     "%Y-%m-%d", |     "%Y-%m-%d", | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  | # LaTeX rendering settings (django-tex) | ||||||
|  | latex_settings = CONFIG.get('latex', {}) | ||||||
|  |  | ||||||
| # Static files (CSS, JavaScript, Images) | # Set the latex interpreter in the config.yaml settings file | ||||||
| # https://docs.djangoproject.com/en/1.10/howto/static-files/ | LATEX_INTERPRETER = latex_settings.get('interpreter', 'pdflatex') | ||||||
|  |  | ||||||
| # Web URL endpoint for served static files | LATEX_INTERPRETER_OPTIONS = latex_settings.get('options', '') | ||||||
| STATIC_URL = '/static/' |  | ||||||
|  |  | ||||||
| # The filesystem location for served static files | LATEX_GRAPHICSPATH = [ | ||||||
| STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static'))) |     # Allow LaTeX files to access the report assets directory | ||||||
|  |     os.path.join(MEDIA_ROOT, "report", "assets"), | ||||||
| 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) |  | ||||||
|  |  | ||||||
| # crispy forms use the bootstrap templates | # crispy forms use the bootstrap templates | ||||||
| CRISPY_TEMPLATE_PACK = 'bootstrap3' | CRISPY_TEMPLATE_PACK = 'bootstrap3' | ||||||
|  |  | ||||||
|   | |||||||
| @@ -166,6 +166,13 @@ class AjaxMixin(object): | |||||||
|             except AttributeError: |             except AttributeError: | ||||||
|                 context = {} |                 context = {} | ||||||
|  |  | ||||||
|  |         # If no 'form' argument is supplied, look at the underlying class | ||||||
|  |         if form is None: | ||||||
|  |             try: | ||||||
|  |                 form = self.get_form() | ||||||
|  |             except AttributeError: | ||||||
|  |                 pass | ||||||
|  |  | ||||||
|         if form: |         if form: | ||||||
|             context['form'] = form |             context['form'] = form | ||||||
|         else: |         else: | ||||||
|   | |||||||
| @@ -73,3 +73,14 @@ log_queries: False | |||||||
| sentry: | sentry: | ||||||
|   enabled: False |   enabled: False | ||||||
|   # dsn: add-your-sentry-dsn-here |   # dsn: add-your-sentry-dsn-here | ||||||
|  |  | ||||||
|  | # LaTeX report rendering | ||||||
|  | # InvenTree uses the django-tex plugin to enable LaTeX report rendering | ||||||
|  | # Ref: https://pypi.org/project/django-tex/ | ||||||
|  | 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 InvenTree.status_codes import BuildStatus, PurchaseOrderStatus | ||||||
|  |  | ||||||
|  | from report import models as ReportModels | ||||||
| from build import models as BuildModels | from build import models as BuildModels | ||||||
| from order import models as OrderModels | from order import models as OrderModels | ||||||
| from company.models import SupplierPart | from company.models import SupplierPart | ||||||
| @@ -358,6 +359,24 @@ class Part(MPTTModel): | |||||||
|         self.category = category |         self.category = category | ||||||
|         self.save() |         self.save() | ||||||
|  |  | ||||||
|  |     def get_test_report_templates(self): | ||||||
|  |         """ | ||||||
|  |         Return all the TestReport template objects which map to this Part. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         templates = [] | ||||||
|  |  | ||||||
|  |         for report in ReportModels.TestReport.objects.all(): | ||||||
|  |             if report.matches_part(self): | ||||||
|  |                 templates.append(report) | ||||||
|  |  | ||||||
|  |         return templates | ||||||
|  |  | ||||||
|  |     def has_test_report_templates(self): | ||||||
|  |         """ Return True if this part has a TestReport defined """ | ||||||
|  |  | ||||||
|  |         return len(self.get_test_report_templates()) > 0 | ||||||
|  |  | ||||||
|     def get_absolute_url(self): |     def get_absolute_url(self): | ||||||
|         """ Return the web URL for viewing this part """ |         """ Return the web URL for viewing this part """ | ||||||
|         return reverse('part-detail', kwargs={'pk': self.id}) |         return reverse('part-detail', kwargs={'pk': self.id}) | ||||||
|   | |||||||
							
								
								
									
										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): | class ExportOptionsForm(HelperForm): | ||||||
|     """ Form for selecting stock export options """ |     """ Form for selecting stock export options """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -963,6 +963,13 @@ class StockItem(MPTTModel): | |||||||
|  |  | ||||||
|         return result_map |         return result_map | ||||||
|  |  | ||||||
|  |     def testResultList(self, **kwargs): | ||||||
|  |         """ | ||||||
|  |         Return a list of test-result objects for this StockItem | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         return self.testResultMap(**kwargs).values() | ||||||
|  |  | ||||||
|     def requiredTestStatus(self): |     def requiredTestStatus(self): | ||||||
|         """ |         """ | ||||||
|         Return the status of the tests required for this StockItem. |         Return the status of the tests required for this StockItem. | ||||||
|   | |||||||
| @@ -93,8 +93,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} | |||||||
|         <span class='fas fa-copy'/> |         <span class='fas fa-copy'/> | ||||||
|     </button> |     </button> | ||||||
|     {% endif %} |     {% endif %} | ||||||
|  |     {% if item.part.has_test_report_templates %} | ||||||
|  |     <button type='button' class='btn btn-default' id='stock-test-report' title='Generate test report'> | ||||||
|  |         <span class='fas fa-tasks'/> | ||||||
|  |     </button> | ||||||
|  |     {% endif %} | ||||||
|     <button type='button' class='btn btn-default' id='stock-edit' title='Edit stock item'> |     <button type='button' class='btn btn-default' id='stock-edit' title='Edit stock item'> | ||||||
|         <span class='fas fa-edit'/> |         <span class='fas fa-edit icon-blue'/> | ||||||
|     </button> |     </button> | ||||||
|     {% if item.can_delete %} |     {% if item.can_delete %} | ||||||
|     <button type='button' class='btn btn-default' id='stock-delete' title='Edit stock item'> |     <button type='button' class='btn btn-default' id='stock-delete' title='Edit stock item'> | ||||||
| @@ -264,6 +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() { | $("#stock-duplicate").click(function() { | ||||||
|     launchModalForm( |     launchModalForm( | ||||||
|         "{% url 'stock-item-create' %}", |         "{% 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'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), | ||||||
|  |  | ||||||
|  |     url(r'^test-report-select/', views.StockItemTestReportSelect.as_view(), name='stock-item-test-report-select'), | ||||||
|  |  | ||||||
|     url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'), |     url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'), | ||||||
|     url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'), |     url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'), | ||||||
|     url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'), |     url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'), | ||||||
| @@ -53,6 +55,8 @@ stock_urls = [ | |||||||
|  |  | ||||||
|     url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'), |     url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'), | ||||||
|  |  | ||||||
|  |     url(r'^item/test-report-download/', views.StockItemTestReportDownload.as_view(), name='stock-item-test-report-download'), | ||||||
|  |  | ||||||
|     # URLs for StockItem attachments |     # URLs for StockItem attachments | ||||||
|     url(r'^item/attachment/', include([ |     url(r'^item/attachment/', include([ | ||||||
|         url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'), |         url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'), | ||||||
|   | |||||||
| @@ -27,19 +27,12 @@ from datetime import datetime | |||||||
|  |  | ||||||
| from company.models import Company, SupplierPart | from company.models import Company, SupplierPart | ||||||
| from part.models import Part | from part.models import Part | ||||||
|  | from report.models import TestReport | ||||||
| from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult | from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult | ||||||
|  |  | ||||||
| from .admin import StockItemResource | from .admin import StockItemResource | ||||||
|  |  | ||||||
| from .forms import EditStockLocationForm | from . import forms as StockForms | ||||||
| from .forms import CreateStockItemForm |  | ||||||
| from .forms import EditStockItemForm |  | ||||||
| from .forms import AdjustStockForm |  | ||||||
| from .forms import TrackingEntryForm |  | ||||||
| from .forms import SerializeStockForm |  | ||||||
| from .forms import ExportOptionsForm |  | ||||||
| from .forms import EditStockItemAttachmentForm |  | ||||||
| from .forms import EditStockItemTestResultForm |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockIndex(ListView): | class StockIndex(ListView): | ||||||
| @@ -114,7 +107,7 @@ class StockLocationEdit(AjaxUpdateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockLocation |     model = StockLocation | ||||||
|     form_class = EditStockLocationForm |     form_class = StockForms.EditStockLocationForm | ||||||
|     context_object_name = 'location' |     context_object_name = 'location' | ||||||
|     ajax_template_name = 'modal_form.html' |     ajax_template_name = 'modal_form.html' | ||||||
|     ajax_form_title = _('Edit Stock Location') |     ajax_form_title = _('Edit Stock Location') | ||||||
| @@ -158,7 +151,7 @@ class StockItemAttachmentCreate(AjaxCreateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItemAttachment |     model = StockItemAttachment | ||||||
|     form_class = EditStockItemAttachmentForm |     form_class = StockForms.EditStockItemAttachmentForm | ||||||
|     ajax_form_title = _("Add Stock Item Attachment") |     ajax_form_title = _("Add Stock Item Attachment") | ||||||
|     ajax_template_name = "modal_form.html" |     ajax_template_name = "modal_form.html" | ||||||
|  |  | ||||||
| @@ -203,7 +196,7 @@ class StockItemAttachmentEdit(AjaxUpdateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItemAttachment |     model = StockItemAttachment | ||||||
|     form_class = EditStockItemAttachmentForm |     form_class = StockForms.EditStockItemAttachmentForm | ||||||
|     ajax_form_title = _("Edit Stock Item Attachment") |     ajax_form_title = _("Edit Stock Item Attachment") | ||||||
|  |  | ||||||
|     def get_form(self): |     def get_form(self): | ||||||
| @@ -271,7 +264,7 @@ class StockItemTestResultCreate(AjaxCreateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItemTestResult |     model = StockItemTestResult | ||||||
|     form_class = EditStockItemTestResultForm |     form_class = StockForms.EditStockItemTestResultForm | ||||||
|     ajax_form_title = _("Add Test Result") |     ajax_form_title = _("Add Test Result") | ||||||
|  |  | ||||||
|     def post_save(self, **kwargs): |     def post_save(self, **kwargs): | ||||||
| @@ -319,7 +312,7 @@ class StockItemTestResultEdit(AjaxUpdateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItemTestResult |     model = StockItemTestResult | ||||||
|     form_class = EditStockItemTestResultForm |     form_class = StockForms.EditStockItemTestResultForm | ||||||
|     ajax_form_title = _("Edit Test Result") |     ajax_form_title = _("Edit Test Result") | ||||||
|  |  | ||||||
|     def get_form(self): |     def get_form(self): | ||||||
| @@ -343,12 +336,81 @@ class StockItemTestResultDelete(AjaxDeleteView): | |||||||
|     context_object_name = "result" |     context_object_name = "result" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class StockItemTestReportSelect(AjaxView): | ||||||
|  |     """ | ||||||
|  |     View for selecting a TestReport template, | ||||||
|  |     and generating a TestReport as a PDF. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     model = StockItem | ||||||
|  |     ajax_form_title = _("Select Test Report Template") | ||||||
|  |  | ||||||
|  |     def get_form(self): | ||||||
|  |  | ||||||
|  |         stock_item = StockItem.objects.get(pk=self.kwargs['pk']) | ||||||
|  |         return StockForms.TestReportFormatForm(stock_item) | ||||||
|  |  | ||||||
|  |     def post(self, request, *args, **kwargs): | ||||||
|  |  | ||||||
|  |         template_id = request.POST.get('template', None) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             template = TestReport.objects.get(pk=template_id) | ||||||
|  |         except (ValueError, TestReport.DoesNoteExist): | ||||||
|  |             raise ValidationError({'template': _("Select valid template")}) | ||||||
|  |  | ||||||
|  |         stock_item = StockItem.objects.get(pk=self.kwargs['pk']) | ||||||
|  |  | ||||||
|  |         url = reverse('stock-item-test-report-download') | ||||||
|  |  | ||||||
|  |         url += '?stock_item={id}'.format(id=stock_item.pk) | ||||||
|  |         url += '&template={id}'.format(id=template.pk) | ||||||
|  |  | ||||||
|  |         data = { | ||||||
|  |             'form_valid': True, | ||||||
|  |             'url': url, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return self.renderJsonResponse(request, self.get_form(), data=data) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class StockItemTestReportDownload(AjaxView): | ||||||
|  |     """ | ||||||
|  |     Download a TestReport against a StockItem. | ||||||
|  |  | ||||||
|  |     Requires the following arguments to be passed as URL params: | ||||||
|  |  | ||||||
|  |     stock_item - Valid PK of a StockItem object | ||||||
|  |     template - Valid PK of a TestReport template object | ||||||
|  |  | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def get(self, request, *args, **kwargs): | ||||||
|  |  | ||||||
|  |         template = request.GET.get('template', None) | ||||||
|  |         stock_item = request.GET.get('stock_item', None) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             template = TestReport.objects.get(pk=template) | ||||||
|  |         except (ValueError, TestReport.DoesNotExist): | ||||||
|  |             raise ValidationError({'template': 'Invalid template ID'}) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             stock_item = StockItem.objects.get(pk=stock_item) | ||||||
|  |         except (ValueError, StockItem.DoesNotExist): | ||||||
|  |             raise ValidationError({'stock_item': 'Invalid StockItem ID'}) | ||||||
|  |  | ||||||
|  |         template.stock_item = stock_item | ||||||
|  |  | ||||||
|  |         return template.render(request) | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockExportOptions(AjaxView): | class StockExportOptions(AjaxView): | ||||||
|     """ Form for selecting StockExport options """ |     """ Form for selecting StockExport options """ | ||||||
|  |  | ||||||
|     model = StockLocation |     model = StockLocation | ||||||
|     ajax_form_title = _('Stock Export Options') |     ajax_form_title = _('Stock Export Options') | ||||||
|     form_class = ExportOptionsForm |     form_class = StockForms.ExportOptionsForm | ||||||
|  |  | ||||||
|     def post(self, request, *args, **kwargs): |     def post(self, request, *args, **kwargs): | ||||||
|  |  | ||||||
| @@ -491,7 +553,7 @@ class StockAdjust(AjaxView, FormMixin): | |||||||
|  |  | ||||||
|     ajax_template_name = 'stock/stock_adjust.html' |     ajax_template_name = 'stock/stock_adjust.html' | ||||||
|     ajax_form_title = _('Adjust Stock') |     ajax_form_title = _('Adjust Stock') | ||||||
|     form_class = AdjustStockForm |     form_class = StockForms.AdjustStockForm | ||||||
|     stock_items = [] |     stock_items = [] | ||||||
|  |  | ||||||
|     def get_GET_items(self): |     def get_GET_items(self): | ||||||
| @@ -809,7 +871,7 @@ class StockItemEdit(AjaxUpdateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItem |     model = StockItem | ||||||
|     form_class = EditStockItemForm |     form_class = StockForms.EditStockItemForm | ||||||
|     context_object_name = 'item' |     context_object_name = 'item' | ||||||
|     ajax_template_name = 'modal_form.html' |     ajax_template_name = 'modal_form.html' | ||||||
|     ajax_form_title = _('Edit Stock Item') |     ajax_form_title = _('Edit Stock Item') | ||||||
| @@ -845,7 +907,7 @@ class StockLocationCreate(AjaxCreateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockLocation |     model = StockLocation | ||||||
|     form_class = EditStockLocationForm |     form_class = StockForms.EditStockLocationForm | ||||||
|     context_object_name = 'location' |     context_object_name = 'location' | ||||||
|     ajax_template_name = 'modal_form.html' |     ajax_template_name = 'modal_form.html' | ||||||
|     ajax_form_title = _('Create new Stock Location') |     ajax_form_title = _('Create new Stock Location') | ||||||
| @@ -870,7 +932,7 @@ class StockItemSerialize(AjaxUpdateView): | |||||||
|     model = StockItem |     model = StockItem | ||||||
|     ajax_template_name = 'stock/item_serialize.html' |     ajax_template_name = 'stock/item_serialize.html' | ||||||
|     ajax_form_title = _('Serialize Stock') |     ajax_form_title = _('Serialize Stock') | ||||||
|     form_class = SerializeStockForm |     form_class = StockForms.SerializeStockForm | ||||||
|  |  | ||||||
|     def get_form(self): |     def get_form(self): | ||||||
|  |  | ||||||
| @@ -879,7 +941,7 @@ class StockItemSerialize(AjaxUpdateView): | |||||||
|         # Pass the StockItem object through to the form |         # Pass the StockItem object through to the form | ||||||
|         context['item'] = self.get_object() |         context['item'] = self.get_object() | ||||||
|  |  | ||||||
|         form = SerializeStockForm(**context) |         form = StockForms.SerializeStockForm(**context) | ||||||
|  |  | ||||||
|         return form |         return form | ||||||
|  |  | ||||||
| @@ -958,7 +1020,7 @@ class StockItemCreate(AjaxCreateView): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model = StockItem |     model = StockItem | ||||||
|     form_class = CreateStockItemForm |     form_class = StockForms.CreateStockItemForm | ||||||
|     context_object_name = 'item' |     context_object_name = 'item' | ||||||
|     ajax_template_name = 'modal_form.html' |     ajax_template_name = 'modal_form.html' | ||||||
|     ajax_form_title = _('Create new Stock Item') |     ajax_form_title = _('Create new Stock Item') | ||||||
| @@ -1265,7 +1327,7 @@ class StockItemTrackingEdit(AjaxUpdateView): | |||||||
|  |  | ||||||
|     model = StockItemTracking |     model = StockItemTracking | ||||||
|     ajax_form_title = _('Edit Stock Tracking Entry') |     ajax_form_title = _('Edit Stock Tracking Entry') | ||||||
|     form_class = TrackingEntryForm |     form_class = StockForms.TrackingEntryForm | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockItemTrackingCreate(AjaxCreateView): | class StockItemTrackingCreate(AjaxCreateView): | ||||||
| @@ -1274,7 +1336,7 @@ class StockItemTrackingCreate(AjaxCreateView): | |||||||
|  |  | ||||||
|     model = StockItemTracking |     model = StockItemTracking | ||||||
|     ajax_form_title = _("Add Stock Tracking Entry") |     ajax_form_title = _("Add Stock Tracking Entry") | ||||||
|     form_class = TrackingEntryForm |     form_class = StockForms.TrackingEntryForm | ||||||
|  |  | ||||||
|     def post(self, request, *args, **kwargs): |     def post(self, request, *args, **kwargs): | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								Makefile
									
									
									
									
									
								
							| @@ -51,12 +51,12 @@ style: | |||||||
| # Run unit tests | # Run unit tests | ||||||
| test: | test: | ||||||
| 	cd InvenTree && python3 manage.py check | 	cd InvenTree && python3 manage.py check | ||||||
| 	cd InvenTree && python3 manage.py test build common company order part stock  | 	cd InvenTree && python3 manage.py test build common company order part report stock InvenTree | ||||||
|  |  | ||||||
| # Run code coverage | # Run code coverage | ||||||
| coverage: | coverage: | ||||||
| 	cd InvenTree && python3 manage.py check | 	cd InvenTree && python3 manage.py check | ||||||
| 	coverage run InvenTree/manage.py test build common company order part stock InvenTree | 	coverage run InvenTree/manage.py test build common company order part report stock InvenTree | ||||||
| 	coverage html | 	coverage html | ||||||
|  |  | ||||||
| # Install packages required to generate code docs | # Install packages required to generate code docs | ||||||
|   | |||||||
| @@ -19,4 +19,6 @@ flake8==3.3.0                   # PEP checking | |||||||
| coverage==4.0.3                 # Unit test coverage | coverage==4.0.3                 # Unit test coverage | ||||||
| python-coveralls==2.9.1         # Coveralls linking (for Travis) | python-coveralls==2.9.1         # Coveralls linking (for Travis) | ||||||
| rapidfuzz==0.7.6                # Fuzzy string matching | rapidfuzz==0.7.6                # Fuzzy string matching | ||||||
| django-stdimage==5.1.1          # Advanced ImageField management | django-stdimage==5.1.1          # Advanced ImageField management | ||||||
|  | django-tex==1.1.7               # LaTeX PDF export | ||||||
|  | django-weasyprint==1.0.1        # HTML PDF export | ||||||
		Reference in New Issue
	
	Block a user