mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
c6d9802010
@ -1,8 +1,6 @@
|
|||||||
[run]
|
[run]
|
||||||
source = ./InvenTree
|
source = ./InvenTree
|
||||||
omit =
|
omit =
|
||||||
# Do not run coverage on migration files
|
|
||||||
*/migrations/*
|
|
||||||
InvenTree/manage.py
|
InvenTree/manage.py
|
||||||
InvenTree/setup.py
|
InvenTree/setup.py
|
||||||
InvenTree/InvenTree/middleware.py
|
InvenTree/InvenTree/middleware.py
|
||||||
|
@ -48,6 +48,10 @@ script:
|
|||||||
- rm inventree_default_db.sqlite3
|
- rm inventree_default_db.sqlite3
|
||||||
- invoke migrate
|
- invoke migrate
|
||||||
- invoke import-records -f data.json
|
- invoke import-records -f data.json
|
||||||
|
# Run linting checks on migration files (django-migration-linter)
|
||||||
|
# Run subset of linting checks on *ALL* migration files
|
||||||
|
# Run strict migration file checks on *NEW* migrations (old ones are what they are)
|
||||||
|
- cd InvenTree && python manage.py lintmigrations 79ddea50f507e34195bad635008419daac0d7a5f -q ok ignore --no-cache && cd ..
|
||||||
|
|
||||||
after_success:
|
after_success:
|
||||||
- coveralls
|
- coveralls
|
@ -492,3 +492,72 @@ def addUserPermissions(user, permissions):
|
|||||||
|
|
||||||
for permission in permissions:
|
for permission in permissions:
|
||||||
addUserPermission(user, permission)
|
addUserPermission(user, permission)
|
||||||
|
|
||||||
|
|
||||||
|
def getMigrationFileNames(app):
|
||||||
|
"""
|
||||||
|
Return a list of all migration filenames for provided app
|
||||||
|
"""
|
||||||
|
|
||||||
|
local_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
migration_dir = os.path.join(local_dir, '..', app, 'migrations')
|
||||||
|
|
||||||
|
files = os.listdir(migration_dir)
|
||||||
|
|
||||||
|
# Regex pattern for migration files
|
||||||
|
pattern = r"^[\d]+_.*\.py$"
|
||||||
|
|
||||||
|
migration_files = []
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
if re.match(pattern, f):
|
||||||
|
migration_files.append(f)
|
||||||
|
|
||||||
|
return migration_files
|
||||||
|
|
||||||
|
|
||||||
|
def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True):
|
||||||
|
"""
|
||||||
|
Return the filename associated with the oldest migration
|
||||||
|
"""
|
||||||
|
|
||||||
|
oldest_num = -1
|
||||||
|
oldest_file = None
|
||||||
|
|
||||||
|
for f in getMigrationFileNames(app):
|
||||||
|
|
||||||
|
if ignore_initial and f.startswith('0001_initial'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
num = int(f.split('_')[0])
|
||||||
|
|
||||||
|
if oldest_file is None or num < oldest_num:
|
||||||
|
oldest_num = num
|
||||||
|
oldest_file = f
|
||||||
|
|
||||||
|
if exclude_extension:
|
||||||
|
oldest_file = oldest_file.replace('.py', '')
|
||||||
|
|
||||||
|
return oldest_file
|
||||||
|
|
||||||
|
|
||||||
|
def getNewestMigrationFile(app, exclude_extension=True):
|
||||||
|
"""
|
||||||
|
Return the filename associated with the newest migration
|
||||||
|
"""
|
||||||
|
|
||||||
|
newest_file = None
|
||||||
|
newest_num = -1
|
||||||
|
|
||||||
|
for f in getMigrationFileNames(app):
|
||||||
|
num = int(f.split('_')[0])
|
||||||
|
|
||||||
|
if newest_file is None or num > newest_num:
|
||||||
|
newest_num = num
|
||||||
|
newest_file = f
|
||||||
|
|
||||||
|
if exclude_extension:
|
||||||
|
newest_file = newest_file.replace('.py', '')
|
||||||
|
|
||||||
|
return newest_file
|
||||||
|
@ -208,11 +208,11 @@ 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
|
|
||||||
'django_admin_shell', # Python shell for the admin interface
|
'django_admin_shell', # Python shell for the admin interface
|
||||||
'djmoney', # django-money integration
|
'djmoney', # django-money integration
|
||||||
'djmoney.contrib.exchange', # django-money exchange rates
|
'djmoney.contrib.exchange', # django-money exchange rates
|
||||||
'error_report', # Error reporting in the admin interface
|
'error_report', # Error reporting in the admin interface
|
||||||
|
'django_migration_linter', # Linting checking for migration files
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = CONFIG.get('middleware', [
|
MIDDLEWARE = CONFIG.get('middleware', [
|
||||||
@ -265,14 +265,6 @@ TEMPLATES = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
# Backend for LaTeX report rendering
|
|
||||||
{
|
|
||||||
'NAME': 'tex',
|
|
||||||
'BACKEND': 'django_tex.engine.TeXEngine',
|
|
||||||
'DIRS': [
|
|
||||||
os.path.join(MEDIA_ROOT, 'report'),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
@ -485,22 +477,6 @@ DATE_INPUT_FORMATS = [
|
|||||||
"%Y-%m-%d",
|
"%Y-%m-%d",
|
||||||
]
|
]
|
||||||
|
|
||||||
# LaTeX rendering settings (django-tex)
|
|
||||||
LATEX_SETTINGS = CONFIG.get('latex', {})
|
|
||||||
|
|
||||||
# Is LaTeX rendering enabled? (Off by default)
|
|
||||||
LATEX_ENABLED = LATEX_SETTINGS.get('enabled', False)
|
|
||||||
|
|
||||||
# Set the latex interpreter in the config.yaml settings file
|
|
||||||
LATEX_INTERPRETER = LATEX_SETTINGS.get('interpreter', 'pdflatex')
|
|
||||||
|
|
||||||
LATEX_INTERPRETER_OPTIONS = LATEX_SETTINGS.get('options', '')
|
|
||||||
|
|
||||||
LATEX_GRAPHICSPATH = [
|
|
||||||
# Allow LaTeX files to access the report assets directory
|
|
||||||
os.path.join(MEDIA_ROOT, "report", "assets"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# crispy forms use the bootstrap templates
|
# crispy forms use the bootstrap templates
|
||||||
CRISPY_TEMPLATE_PACK = 'bootstrap3'
|
CRISPY_TEMPLATE_PACK = 'bootstrap3'
|
||||||
|
|
||||||
|
@ -79,6 +79,7 @@ settings_urls = [
|
|||||||
url(r'^theme/?', ColorThemeSelectView.as_view(), name='settings-theme'),
|
url(r'^theme/?', ColorThemeSelectView.as_view(), name='settings-theme'),
|
||||||
|
|
||||||
url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
|
url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
|
||||||
|
url(r'^report/?', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),
|
||||||
url(r'^category/?', SettingCategorySelectView.as_view(), name='settings-category'),
|
url(r'^category/?', SettingCategorySelectView.as_view(), name='settings-category'),
|
||||||
url(r'^part/?', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
|
url(r'^part/?', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
|
||||||
url(r'^stock/?', SettingsView.as_view(template_name='InvenTree/settings/stock.html'), name='settings-stock'),
|
url(r'^stock/?', SettingsView.as_view(template_name='InvenTree/settings/stock.html'), name='settings-stock'),
|
||||||
|
@ -17,6 +17,8 @@ def nupdate_tree(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('build', '0012_build_sales_order'),
|
('build', '0012_build_sales_order'),
|
||||||
]
|
]
|
||||||
|
@ -9,7 +9,7 @@ def add_default_reference(apps, schema_editor):
|
|||||||
Best we can do is use the PK of the build order itself.
|
Best we can do is use the PK of the build order itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Build = apps.get_model('build', 'Build')
|
Build = apps.get_model('build', 'build')
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
@ -31,6 +31,8 @@ def reverse_default_reference(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('build', '0017_auto_20200426_0612'),
|
('build', '0017_auto_20200426_0612'),
|
||||||
]
|
]
|
||||||
|
118
InvenTree/build/test_migrations.py
Normal file
118
InvenTree/build/test_migrations.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Tests for the build model database migrations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||||
|
|
||||||
|
from InvenTree import helpers
|
||||||
|
|
||||||
|
|
||||||
|
class TestForwardMigrations(MigratorTestCase):
|
||||||
|
"""
|
||||||
|
Test entire schema migration sequence for the build app
|
||||||
|
"""
|
||||||
|
|
||||||
|
migrate_from = ('build', helpers.getOldestMigrationFile('build'))
|
||||||
|
migrate_to = ('build', helpers.getNewestMigrationFile('build'))
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""
|
||||||
|
Create initial data!
|
||||||
|
"""
|
||||||
|
|
||||||
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
|
||||||
|
buildable_part = Part.objects.create(
|
||||||
|
name='Widget',
|
||||||
|
description='Buildable Part',
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
# Cannot set the 'assembly' field as it hasn't been added to the db schema
|
||||||
|
Part.objects.create(
|
||||||
|
name='Blorb',
|
||||||
|
description='ABCDE',
|
||||||
|
assembly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
Build = self.old_state.apps.get_model('build', 'build')
|
||||||
|
|
||||||
|
Build.objects.create(
|
||||||
|
part=buildable_part,
|
||||||
|
title='A build of some stuff',
|
||||||
|
quantity=50
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_items_exist(self):
|
||||||
|
|
||||||
|
Part = self.new_state.apps.get_model('part', 'part')
|
||||||
|
|
||||||
|
self.assertEqual(Part.objects.count(), 1)
|
||||||
|
|
||||||
|
Build = self.new_state.apps.get_model('build', 'build')
|
||||||
|
|
||||||
|
self.assertEqual(Build.objects.count(), 1)
|
||||||
|
|
||||||
|
# Check that the part object now has an assembly field
|
||||||
|
part = Part.objects.all().first()
|
||||||
|
part.assembly = True
|
||||||
|
part.save()
|
||||||
|
part.assembly = False
|
||||||
|
part.save()
|
||||||
|
|
||||||
|
|
||||||
|
class TestReferenceMigration(MigratorTestCase):
|
||||||
|
"""
|
||||||
|
Test custom migration which adds 'reference' field to Build model
|
||||||
|
"""
|
||||||
|
|
||||||
|
migrate_from = ('build', helpers.getOldestMigrationFile('build'))
|
||||||
|
migrate_to = ('build', '0018_build_reference')
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""
|
||||||
|
Create some builds
|
||||||
|
"""
|
||||||
|
|
||||||
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
|
||||||
|
part = Part.objects.create(
|
||||||
|
name='Part',
|
||||||
|
description='A test part'
|
||||||
|
)
|
||||||
|
|
||||||
|
Build = self.old_state.apps.get_model('build', 'build')
|
||||||
|
|
||||||
|
Build.objects.create(
|
||||||
|
part=part,
|
||||||
|
title='My very first build',
|
||||||
|
quantity=10
|
||||||
|
)
|
||||||
|
|
||||||
|
Build.objects.create(
|
||||||
|
part=part,
|
||||||
|
title='My very second build',
|
||||||
|
quantity=10
|
||||||
|
)
|
||||||
|
|
||||||
|
Build.objects.create(
|
||||||
|
part=part,
|
||||||
|
title='My very third build',
|
||||||
|
quantity=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure that the builds *do not* have a 'reference' field
|
||||||
|
for build in Build.objects.all():
|
||||||
|
with self.assertRaises(AttributeError):
|
||||||
|
print(build.reference)
|
||||||
|
|
||||||
|
def test_build_reference(self):
|
||||||
|
|
||||||
|
Build = self.new_state.apps.get_model('build', 'build')
|
||||||
|
|
||||||
|
self.assertEqual(Build.objects.count(), 3)
|
||||||
|
|
||||||
|
# Check that the build reference is properly assigned
|
||||||
|
for build in Build.objects.all():
|
||||||
|
self.assertEqual(str(build.reference), str(build.pk))
|
@ -174,6 +174,13 @@ class InvenTreeSetting(models.Model):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'REPORT_ENABLE_TEST_REPORT': {
|
||||||
|
'name': _('Test Reports'),
|
||||||
|
'description': _('Enable generation of test reports'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
|
||||||
'STOCK_ENABLE_EXPIRY': {
|
'STOCK_ENABLE_EXPIRY': {
|
||||||
'name': _('Stock Expiry'),
|
'name': _('Stock Expiry'),
|
||||||
'description': _('Enable stock expiry functionality'),
|
'description': _('Enable stock expiry functionality'),
|
||||||
|
@ -1,15 +1,21 @@
|
|||||||
# Generated by Django 2.2.10 on 2020-04-13 06:42
|
# Generated by Django 2.2.10 on 2020-04-13 06:42
|
||||||
|
|
||||||
|
import sys
|
||||||
import os
|
import os
|
||||||
from rapidfuzz import fuzz
|
from rapidfuzz import fuzz
|
||||||
|
|
||||||
from django.db import migrations, connection
|
from django.db import migrations, connection
|
||||||
from company.models import Company, SupplierPart
|
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
|
|
||||||
|
"""
|
||||||
|
When this migration is tested by CI, it cannot accept user input.
|
||||||
|
So a simplified version of the migration is implemented.
|
||||||
|
"""
|
||||||
|
TESTING = 'test' in sys.argv
|
||||||
|
|
||||||
def clear():
|
def clear():
|
||||||
os.system('cls' if os.name == 'nt' else 'clear')
|
if not TESTING:
|
||||||
|
os.system('cls' if os.name == 'nt' else 'clear')
|
||||||
|
|
||||||
|
|
||||||
def reverse_association(apps, schema_editor):
|
def reverse_association(apps, schema_editor):
|
||||||
@ -21,25 +27,29 @@ def reverse_association(apps, schema_editor):
|
|||||||
into the 'manufacturer_name' field.
|
into the 'manufacturer_name' field.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
cursor = connection.cursor()
|
||||||
|
|
||||||
|
response = cursor.execute('select id, "MPN" from part_supplierpart;')
|
||||||
|
supplier_parts = cursor.fetchall()
|
||||||
|
|
||||||
# Exit if there are no SupplierPart objects
|
# Exit if there are no SupplierPart objects
|
||||||
# This crucial otherwise the unit test suite fails!
|
# This crucial otherwise the unit test suite fails!
|
||||||
if SupplierPart.objects.count() == 0:
|
if len(supplier_parts) == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
print("Reversing migration for manufacturer association")
|
print("Reversing migration for manufacturer association")
|
||||||
|
|
||||||
for part in SupplierPart.objects.all():
|
for (index, row) in enumerate(supplier_parts):
|
||||||
|
supplier_part_id, MPN = row
|
||||||
|
|
||||||
print("Checking part [{pk}]:".format(pk=part.pk))
|
print(f"Checking SupplierPart [{supplier_part_id}]:")
|
||||||
|
|
||||||
cursor = connection.cursor()
|
|
||||||
|
|
||||||
# Grab the manufacturer ID from the part
|
# Grab the manufacturer ID from the part
|
||||||
response = cursor.execute('SELECT manufacturer_id FROM part_supplierpart WHERE id={ID};'.format(ID=part.id))
|
response = cursor.execute(f"SELECT manufacturer_id FROM part_supplierpart WHERE id={supplier_part_id};")
|
||||||
|
|
||||||
manufacturer_id = None
|
manufacturer_id = None
|
||||||
|
|
||||||
row = response.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
if len(row) > 0:
|
if len(row) > 0:
|
||||||
try:
|
try:
|
||||||
@ -54,15 +64,15 @@ def reverse_association(apps, schema_editor):
|
|||||||
print(" - Manufacturer ID: [{id}]".format(id=manufacturer_id))
|
print(" - Manufacturer ID: [{id}]".format(id=manufacturer_id))
|
||||||
|
|
||||||
# Now extract the "name" for the manufacturer
|
# Now extract the "name" for the manufacturer
|
||||||
response = cursor.execute('SELECT name from company_company where id={ID};'.format(ID=manufacturer_id))
|
response = cursor.execute(f"SELECT name from company_company where id={manufacturer_id};")
|
||||||
|
|
||||||
row = response.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
name = row[0]
|
name = row[0]
|
||||||
|
|
||||||
print(" - Manufacturer name: '{name}'".format(name=name))
|
print(" - Manufacturer name: '{name}'".format(name=name))
|
||||||
|
|
||||||
response = cursor.execute("UPDATE part_supplierpart SET manufacturer_name='{name}' WHERE id={ID};".format(name=name, ID=part.id))
|
response = cursor.execute("UPDATE part_supplierpart SET manufacturer_name='{name}' WHERE id={ID};".format(name=name, ID=supplier_part_id))
|
||||||
|
|
||||||
def associate_manufacturers(apps, schema_editor):
|
def associate_manufacturers(apps, schema_editor):
|
||||||
"""
|
"""
|
||||||
@ -100,10 +110,14 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
return row[0]
|
return row[0]
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
cursor = connection.cursor()
|
||||||
|
|
||||||
|
response = cursor.execute(f'select id, "MPN" from part_supplierpart;')
|
||||||
|
supplier_parts = cursor.fetchall()
|
||||||
|
|
||||||
# Exit if there are no SupplierPart objects
|
# Exit if there are no SupplierPart objects
|
||||||
# This crucial otherwise the unit test suite fails!
|
# This crucial otherwise the unit test suite fails!
|
||||||
if SupplierPart.objects.count() == 0:
|
if len(supplier_parts) == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Link a 'manufacturer_name' to a 'Company'
|
# Link a 'manufacturer_name' to a 'Company'
|
||||||
@ -112,50 +126,63 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
# Map company names to company objects
|
# Map company names to company objects
|
||||||
companies = {}
|
companies = {}
|
||||||
|
|
||||||
for company in Company.objects.all():
|
# Iterate through each company object
|
||||||
companies[company.name] = company
|
response = cursor.execute("select id, name from company_company;")
|
||||||
|
results = cursor.fetchall()
|
||||||
|
|
||||||
def link_part(part, name):
|
for index, row in enumerate(results):
|
||||||
|
pk, name = row
|
||||||
|
|
||||||
|
companies[name] = pk
|
||||||
|
|
||||||
|
def link_part(part_id, name):
|
||||||
""" Attempt to link Part to an existing Company """
|
""" Attempt to link Part to an existing Company """
|
||||||
|
|
||||||
# Matches a company name directly
|
# Matches a company name directly
|
||||||
if name in companies.keys():
|
if name in companies.keys():
|
||||||
print(" - Part[{pk}]: '{n}' maps to existing manufacturer".format(pk=part.pk, n=name))
|
print(" - Part[{pk}]: '{n}' maps to existing manufacturer".format(pk=part_id, n=name))
|
||||||
part.manufacturer = companies[name]
|
|
||||||
part.save()
|
manufacturer_id = companies[name]
|
||||||
|
|
||||||
|
query = f"update part_supplierpart set manufacturer_id={manufacturer_id} where id={part_id};"
|
||||||
|
result = cursor.execute(query)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Have we already mapped this
|
# Have we already mapped this
|
||||||
if name in links.keys():
|
if name in links.keys():
|
||||||
print(" - Part[{pk}]: Mapped '{n}' - '{c}'".format(pk=part.pk, n=name, c=links[name].name))
|
print(" - Part[{pk}]: Mapped '{n}' - manufacturer <{c}>".format(pk=part_id, n=name, c=links[name]))
|
||||||
part.manufacturer = links[name]
|
|
||||||
part.save()
|
manufacturer_id = links[name]
|
||||||
|
|
||||||
|
query = f"update part_supplierpart set manufacturer_id={manufacturer_id} where id={part_id};"
|
||||||
|
result = cursor.execute(query)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Mapping not possible
|
# Mapping not possible
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def create_manufacturer(part, input_name, company_name):
|
def create_manufacturer(part_id, input_name, company_name):
|
||||||
""" Create a new manufacturer """
|
""" Create a new manufacturer """
|
||||||
|
|
||||||
company = Company(name=company_name, description=company_name, is_manufacturer=True)
|
Company = apps.get_model('company', 'company')
|
||||||
|
|
||||||
company.is_manufacturer = True
|
manufacturer = Company.objects.create(
|
||||||
|
name=company_name,
|
||||||
# Save the company BEFORE we associate the part, otherwise the PK does not exist
|
description=company_name,
|
||||||
company.save()
|
is_manufacturer=True
|
||||||
|
)
|
||||||
|
|
||||||
# Map both names to the same company
|
# Map both names to the same company
|
||||||
links[input_name] = company
|
links[input_name] = manufacturer.pk
|
||||||
links[company_name] = company
|
links[company_name] = manufacturer.pk
|
||||||
|
|
||||||
companies[company_name] = company
|
companies[company_name] = manufacturer.pk
|
||||||
|
|
||||||
print(" - Part[{pk}]: Created new manufacturer: '{name}'".format(pk=part.pk, name=company_name))
|
print(" - Part[{pk}]: Created new manufacturer: '{name}'".format(pk=part_id, name=company_name))
|
||||||
|
|
||||||
# Save the manufacturer reference link
|
# Update SupplierPart object in the database
|
||||||
part.manufacturer = company
|
cursor.execute(f"update part_supplierpart set manufacturer_id={manufacturer.pk} where id={part_id};")
|
||||||
part.save()
|
|
||||||
|
|
||||||
def find_matches(text, threshold=65):
|
def find_matches(text, threshold=65):
|
||||||
"""
|
"""
|
||||||
@ -178,17 +205,19 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def map_part_to_manufacturer(part, idx, total):
|
def map_part_to_manufacturer(part_id, idx, total):
|
||||||
|
|
||||||
name = get_manufacturer_name(part.id)
|
cursor = connection.cursor()
|
||||||
|
|
||||||
|
name = get_manufacturer_name(part_id)
|
||||||
|
|
||||||
# Skip empty names
|
# Skip empty names
|
||||||
if not name or len(name) == 0:
|
if not name or len(name) == 0:
|
||||||
print(" - Part[{pk}]: No manufacturer_name provided, skipping".format(pk=part.pk))
|
print(" - Part[{pk}]: No manufacturer_name provided, skipping".format(pk=part_id))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Can be linked to an existing manufacturer
|
# Can be linked to an existing manufacturer
|
||||||
if link_part(part, name):
|
if link_part(part_id, name):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find a list of potential matches
|
# Find a list of potential matches
|
||||||
@ -197,23 +226,31 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
clear()
|
clear()
|
||||||
|
|
||||||
# Present a list of options
|
# Present a list of options
|
||||||
print("----------------------------------")
|
if not TESTING:
|
||||||
print("Checking part [{pk}] ({idx} of {total})".format(pk=part.pk, idx=idx+1, total=total))
|
print("----------------------------------")
|
||||||
print("Manufacturer name: '{n}'".format(n=name))
|
|
||||||
print("----------------------------------")
|
print("Checking part [{pk}] ({idx} of {total})".format(pk=part_id, idx=idx+1, total=total))
|
||||||
print("Select an option from the list below:")
|
|
||||||
|
if not TESTING:
|
||||||
|
print("Manufacturer name: '{n}'".format(n=name))
|
||||||
|
print("----------------------------------")
|
||||||
|
print("Select an option from the list below:")
|
||||||
|
|
||||||
print("0) - Create new manufacturer '{n}'".format(n=name))
|
print("0) - Create new manufacturer '{n}'".format(n=name))
|
||||||
print("")
|
print("")
|
||||||
|
|
||||||
for i, m in enumerate(matches[:10]):
|
for i, m in enumerate(matches[:10]):
|
||||||
print("{i}) - Use manufacturer '{opt}'".format(i=i+1, opt=m))
|
print("{i}) - Use manufacturer '{opt}'".format(i=i+1, opt=m))
|
||||||
|
|
||||||
print("")
|
print("")
|
||||||
print("OR - Type a new custom manufacturer name")
|
print("OR - Type a new custom manufacturer name")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
response = str(input("> ")).strip()
|
if TESTING:
|
||||||
|
# When running unit tests, simply select the name of the part
|
||||||
|
response = '0'
|
||||||
|
else:
|
||||||
|
response = str(input("> ")).strip()
|
||||||
|
|
||||||
# Attempt to parse user response as an integer
|
# Attempt to parse user response as an integer
|
||||||
try:
|
try:
|
||||||
@ -222,7 +259,7 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
# Option 0) is to create a new manufacturer with the current name
|
# Option 0) is to create a new manufacturer with the current name
|
||||||
if n == 0:
|
if n == 0:
|
||||||
|
|
||||||
create_manufacturer(part, name, name)
|
create_manufacturer(part_id, name, name)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Options 1) - n) select an existing manufacturer
|
# Options 1) - n) select an existing manufacturer
|
||||||
@ -232,21 +269,19 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
if n < len(matches):
|
if n < len(matches):
|
||||||
# Get the company which matches the selected options
|
# Get the company which matches the selected options
|
||||||
company_name = matches[n]
|
company_name = matches[n]
|
||||||
company = companies[company_name]
|
company_id = companies[company_name]
|
||||||
|
|
||||||
# Ensure the company is designated as a manufacturer
|
# Ensure the company is designated as a manufacturer
|
||||||
company.is_manufacturer = True
|
cursor.execute(f"update company_company set is_manufacturer=true where id={company_id};")
|
||||||
company.save()
|
|
||||||
|
|
||||||
# Link the company to the part
|
# Link the company to the part
|
||||||
part.manufacturer = company
|
cursor.execute(f"update part_supplierpart set manufacturer_id={company_id} where id={part_id};")
|
||||||
part.save()
|
|
||||||
|
|
||||||
# Link the name to the company
|
# Link the name to the company
|
||||||
links[name] = company
|
links[name] = company_id
|
||||||
links[company_name] = company
|
links[company_name] = company_id
|
||||||
|
|
||||||
print(" - Part[{pk}]: Linked '{n}' to manufacturer '{m}'".format(pk=part.pk, n=name, m=company_name))
|
print(" - Part[{pk}]: Linked '{n}' to manufacturer '{m}'".format(pk=part_id, n=name, m=company_name))
|
||||||
|
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
@ -270,43 +305,52 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
|
|
||||||
# No match, create a new manufacturer
|
# No match, create a new manufacturer
|
||||||
else:
|
else:
|
||||||
create_manufacturer(part, name, response)
|
create_manufacturer(part_id, name, response)
|
||||||
return
|
return
|
||||||
|
|
||||||
clear()
|
clear()
|
||||||
print("")
|
print("")
|
||||||
clear()
|
clear()
|
||||||
|
|
||||||
print("---------------------------------------")
|
if not TESTING:
|
||||||
print("The SupplierPart model needs to be migrated,")
|
print("---------------------------------------")
|
||||||
print("as the new 'manufacturer' field maps to a 'Company' reference.")
|
print("The SupplierPart model needs to be migrated,")
|
||||||
print("The existing 'manufacturer_name' field will be used to match")
|
print("as the new 'manufacturer' field maps to a 'Company' reference.")
|
||||||
print("against possible companies.")
|
print("The existing 'manufacturer_name' field will be used to match")
|
||||||
print("This process requires user input.")
|
print("against possible companies.")
|
||||||
print("")
|
print("This process requires user input.")
|
||||||
print("Note: This process MUST be completed to migrate the database.")
|
print("")
|
||||||
print("---------------------------------------")
|
print("Note: This process MUST be completed to migrate the database.")
|
||||||
print("")
|
print("---------------------------------------")
|
||||||
|
print("")
|
||||||
|
|
||||||
input("Press <ENTER> to continue.")
|
input("Press <ENTER> to continue.")
|
||||||
|
|
||||||
clear()
|
clear()
|
||||||
|
|
||||||
part_count = SupplierPart.objects.count()
|
# Extract all SupplierPart objects from the database
|
||||||
|
cursor = connection.cursor()
|
||||||
|
response = cursor.execute('select id, "MPN", "SKU", manufacturer_id, manufacturer_name from part_supplierpart;')
|
||||||
|
results = cursor.fetchall()
|
||||||
|
|
||||||
|
part_count = len(results)
|
||||||
|
|
||||||
# Create a unique set of manufacturer names
|
# Create a unique set of manufacturer names
|
||||||
for idx, part in enumerate(SupplierPart.objects.all()):
|
for index, row in enumerate(results):
|
||||||
|
pk, MPN, SKU, manufacturer_id, manufacturer_name = row
|
||||||
|
|
||||||
if part.manufacturer is not None:
|
if manufacturer_id is not None:
|
||||||
print(" - Part '{p}' already has a manufacturer associated (skipping)".format(p=part))
|
print(f" - SupplierPart <{pk}> already has a manufacturer associated (skipping)")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
map_part_to_manufacturer(part, idx, part_count)
|
map_part_to_manufacturer(pk, index, part_count)
|
||||||
|
|
||||||
print("Done!")
|
print("Done!")
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('company', '0018_supplierpart_manufacturer'),
|
('company', '0018_supplierpart_manufacturer'),
|
||||||
]
|
]
|
||||||
|
@ -1,6 +1,14 @@
|
|||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_empty_email(apps, schema_editor):
|
||||||
|
Company = apps.get_model('company', 'Company')
|
||||||
|
for company in Company.objects.all():
|
||||||
|
if company.email == None:
|
||||||
|
company.email = ''
|
||||||
|
company.save()
|
||||||
|
|
||||||
|
|
||||||
def make_empty_email_field_null(apps, schema_editor):
|
def make_empty_email_field_null(apps, schema_editor):
|
||||||
Company = apps.get_model('company', 'Company')
|
Company = apps.get_model('company', 'Company')
|
||||||
for company in Company.objects.all():
|
for company in Company.objects.all():
|
||||||
@ -11,6 +19,8 @@ def make_empty_email_field_null(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('company', '0023_auto_20200808_0715'),
|
('company', '0023_auto_20200808_0715'),
|
||||||
]
|
]
|
||||||
@ -23,7 +33,7 @@ class Migration(migrations.Migration):
|
|||||||
field=models.EmailField(blank=True, help_text='Contact email address', max_length=254, null=True, unique=False, verbose_name='Email'),
|
field=models.EmailField(blank=True, help_text='Contact email address', max_length=254, null=True, unique=False, verbose_name='Email'),
|
||||||
),
|
),
|
||||||
# Convert empty email string to NULL
|
# Convert empty email string to NULL
|
||||||
migrations.RunPython(make_empty_email_field_null),
|
migrations.RunPython(make_empty_email_field_null, reverse_code=reverse_empty_email),
|
||||||
# Remove unique constraint on name field
|
# Remove unique constraint on name field
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='company',
|
model_name='company',
|
||||||
|
@ -106,7 +106,7 @@ def reverse_currencies(apps, schema_editor):
|
|||||||
# For each currency code in use, check if we have a matching Currency object
|
# For each currency code in use, check if we have a matching Currency object
|
||||||
for code in codes_in_use:
|
for code in codes_in_use:
|
||||||
response = cursor.execute(f"SELECT id, suffix from common_currency where suffix='{code}';")
|
response = cursor.execute(f"SELECT id, suffix from common_currency where suffix='{code}';")
|
||||||
row = response.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
if row is not None:
|
if row is not None:
|
||||||
# A match exists!
|
# A match exists!
|
||||||
@ -138,6 +138,8 @@ def reverse_currencies(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('company', '0025_auto_20201110_1001'),
|
('company', '0025_auto_20201110_1001'),
|
||||||
]
|
]
|
||||||
|
171
InvenTree/company/test_migrations.py
Normal file
171
InvenTree/company/test_migrations.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
Tests for the company model database migrations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||||
|
|
||||||
|
from InvenTree import helpers
|
||||||
|
|
||||||
|
|
||||||
|
class TestForwardMigrations(MigratorTestCase):
|
||||||
|
|
||||||
|
migrate_from = ('company', helpers.getOldestMigrationFile('company'))
|
||||||
|
migrate_to = ('company', helpers.getNewestMigrationFile('company'))
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""
|
||||||
|
Create some simple Company data, and ensure that it migrates OK
|
||||||
|
"""
|
||||||
|
|
||||||
|
Company = self.old_state.apps.get_model('company', 'company')
|
||||||
|
|
||||||
|
Company.objects.create(
|
||||||
|
name='MSPC',
|
||||||
|
description='Michael Scotts Paper Company',
|
||||||
|
is_supplier=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_migrations(self):
|
||||||
|
|
||||||
|
Company = self.new_state.apps.get_model('company', 'company')
|
||||||
|
|
||||||
|
self.assertEqual(Company.objects.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestManufacturerField(MigratorTestCase):
|
||||||
|
"""
|
||||||
|
Tests for migration 0019 which migrates from old 'manufacturer_name' field to new 'manufacturer' field
|
||||||
|
"""
|
||||||
|
|
||||||
|
migrate_from = ('company', '0018_supplierpart_manufacturer')
|
||||||
|
migrate_to = ('company', '0019_auto_20200413_0642')
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""
|
||||||
|
Prepare the database by adding some test data 'before' the change:
|
||||||
|
|
||||||
|
- Part object
|
||||||
|
- Company object (supplier)
|
||||||
|
- SupplierPart object
|
||||||
|
"""
|
||||||
|
|
||||||
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
Company = self.old_state.apps.get_model('company', 'company')
|
||||||
|
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
|
||||||
|
|
||||||
|
# Create an initial part
|
||||||
|
part = Part.objects.create(
|
||||||
|
name='Screw',
|
||||||
|
description='A single screw'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a company to act as the supplier
|
||||||
|
supplier = Company.objects.create(
|
||||||
|
name='Supplier',
|
||||||
|
description='A supplier of parts',
|
||||||
|
is_supplier=True,
|
||||||
|
is_customer=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add some SupplierPart objects
|
||||||
|
SupplierPart.objects.create(
|
||||||
|
part=part,
|
||||||
|
supplier=supplier,
|
||||||
|
SKU='SCREW.001',
|
||||||
|
manufacturer_name='ACME',
|
||||||
|
)
|
||||||
|
|
||||||
|
SupplierPart.objects.create(
|
||||||
|
part=part,
|
||||||
|
supplier=supplier,
|
||||||
|
SKU='SCREW.002',
|
||||||
|
manufacturer_name='Zero Corp'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Company.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_company_objects(self):
|
||||||
|
"""
|
||||||
|
Test that the new companies have been created successfully
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Two additional company objects should have been created
|
||||||
|
Company = self.new_state.apps.get_model('company', 'company')
|
||||||
|
self.assertEqual(Company.objects.count(), 3)
|
||||||
|
|
||||||
|
# The new company/ies must be marked as "manufacturers"
|
||||||
|
acme = Company.objects.get(name='ACME')
|
||||||
|
self.assertTrue(acme.is_manufacturer)
|
||||||
|
|
||||||
|
SupplierPart = self.new_state.apps.get_model('company', 'supplierpart')
|
||||||
|
parts = SupplierPart.objects.filter(manufacturer=acme)
|
||||||
|
self.assertEqual(parts.count(), 1)
|
||||||
|
part = parts.first()
|
||||||
|
|
||||||
|
# Checks on the SupplierPart object
|
||||||
|
self.assertEqual(part.manufacturer_name, 'ACME')
|
||||||
|
self.assertEqual(part.manufacturer.name, 'ACME')
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrencyMigration(MigratorTestCase):
|
||||||
|
"""
|
||||||
|
Tests for upgrade from basic currency support to django-money
|
||||||
|
"""
|
||||||
|
|
||||||
|
migrate_from = ('company', '0025_auto_20201110_1001')
|
||||||
|
migrate_to = ('company', '0026_auto_20201110_1011')
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""
|
||||||
|
Prepare some data:
|
||||||
|
|
||||||
|
- A part to buy
|
||||||
|
- A supplier to buy from
|
||||||
|
- A supplier part
|
||||||
|
- Multiple currency objects
|
||||||
|
- Multiple supplier price breaks
|
||||||
|
"""
|
||||||
|
|
||||||
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
|
||||||
|
part = Part.objects.create(
|
||||||
|
name="PART", description="A purchaseable part",
|
||||||
|
purchaseable=True,
|
||||||
|
level=0,
|
||||||
|
tree_id=0,
|
||||||
|
lft=0,
|
||||||
|
rght=0
|
||||||
|
)
|
||||||
|
|
||||||
|
Company = self.old_state.apps.get_model('company', 'company')
|
||||||
|
|
||||||
|
supplier = Company.objects.create(name='Supplier', description='A supplier', is_supplier=True)
|
||||||
|
|
||||||
|
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
|
||||||
|
|
||||||
|
sp = SupplierPart.objects.create(part=part, supplier=supplier, SKU='12345')
|
||||||
|
|
||||||
|
Currency = self.old_state.apps.get_model('common', 'currency')
|
||||||
|
|
||||||
|
aud = Currency.objects.create(symbol='$', suffix='AUD', description='Australian Dollars', value=1.0)
|
||||||
|
usd = Currency.objects.create(symbol='$', suffix='USD', description='US Dollars', value=1.0)
|
||||||
|
|
||||||
|
PB = self.old_state.apps.get_model('company', 'supplierpricebreak')
|
||||||
|
|
||||||
|
PB.objects.create(part=sp, quantity=10, cost=5, currency=aud)
|
||||||
|
PB.objects.create(part=sp, quantity=20, cost=3, currency=aud)
|
||||||
|
PB.objects.create(part=sp, quantity=30, cost=2, currency=aud)
|
||||||
|
|
||||||
|
PB.objects.create(part=sp, quantity=40, cost=2, currency=usd)
|
||||||
|
PB.objects.create(part=sp, quantity=50, cost=2, currency=usd)
|
||||||
|
|
||||||
|
for pb in PB.objects.all():
|
||||||
|
self.assertIsNone(pb.price)
|
||||||
|
|
||||||
|
def test_currency_migration(self):
|
||||||
|
|
||||||
|
PB = self.new_state.apps.get_model('company', 'supplierpricebreak')
|
||||||
|
|
||||||
|
for pb in PB.objects.all():
|
||||||
|
# Test that a price has been assigned
|
||||||
|
self.assertIsNotNone(pb.price)
|
@ -107,19 +107,6 @@ static_root: '../inventree_static'
|
|||||||
# If unspecified, the local user's temp directory will be used
|
# If unspecified, the local user's temp directory will be used
|
||||||
#backup_dir: '/home/inventree/backup/'
|
#backup_dir: '/home/inventree/backup/'
|
||||||
|
|
||||||
# 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: ''
|
|
||||||
|
|
||||||
# Permit custom authentication backends
|
# Permit custom authentication backends
|
||||||
#authentication_backends:
|
#authentication_backends:
|
||||||
# - 'django.contrib.auth.backends.ModelBackend'
|
# - 'django.contrib.auth.backends.ModelBackend'
|
||||||
|
@ -12,6 +12,8 @@ def update_tree(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('part', '0019_auto_20190908_0404'),
|
('part', '0019_auto_20190908_0404'),
|
||||||
]
|
]
|
||||||
|
@ -18,6 +18,8 @@ def create_thumbnails(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('part', '0033_auto_20200404_0445'),
|
('part', '0033_auto_20200404_0445'),
|
||||||
]
|
]
|
||||||
|
@ -16,6 +16,8 @@ def nupdate_tree(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('part', '0038_auto_20200513_0016'),
|
('part', '0038_auto_20200513_0016'),
|
||||||
]
|
]
|
||||||
|
@ -106,7 +106,7 @@ def reverse_currencies(apps, schema_editor):
|
|||||||
# For each currency code in use, check if we have a matching Currency object
|
# For each currency code in use, check if we have a matching Currency object
|
||||||
for code in codes_in_use:
|
for code in codes_in_use:
|
||||||
response = cursor.execute(f"SELECT id, suffix from common_currency where suffix='{code}';")
|
response = cursor.execute(f"SELECT id, suffix from common_currency where suffix='{code}';")
|
||||||
row = response.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
if row is not None:
|
if row is not None:
|
||||||
# A match exists!
|
# A match exists!
|
||||||
@ -138,6 +138,8 @@ def reverse_currencies(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('part', '0055_auto_20201110_1001'),
|
('part', '0055_auto_20201110_1001'),
|
||||||
]
|
]
|
||||||
|
@ -44,7 +44,6 @@
|
|||||||
<span id='part-star-icon' class='fas fa-star {% if starred %}icon-yellow{% endif %}'/>
|
<span id='part-star-icon' class='fas fa-star {% if starred %}icon-yellow{% endif %}'/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
|
||||||
{% if barcodes %}
|
{% if barcodes %}
|
||||||
<!-- Barcode actions menu -->
|
<!-- Barcode actions menu -->
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
|
51
InvenTree/part/test_migrations.py
Normal file
51
InvenTree/part/test_migrations.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the part model database migrations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||||
|
|
||||||
|
from InvenTree import helpers
|
||||||
|
|
||||||
|
|
||||||
|
class TestForwardMigrations(MigratorTestCase):
|
||||||
|
"""
|
||||||
|
Test entire schema migration sequence for the part app
|
||||||
|
"""
|
||||||
|
|
||||||
|
migrate_from = ('part', helpers.getOldestMigrationFile('part'))
|
||||||
|
migrate_to = ('part', helpers.getNewestMigrationFile('part'))
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""
|
||||||
|
Create initial data
|
||||||
|
"""
|
||||||
|
|
||||||
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
|
||||||
|
Part.objects.create(name='A', description='My part A')
|
||||||
|
Part.objects.create(name='B', description='My part B')
|
||||||
|
Part.objects.create(name='C', description='My part C')
|
||||||
|
Part.objects.create(name='D', description='My part D')
|
||||||
|
Part.objects.create(name='E', description='My part E')
|
||||||
|
|
||||||
|
# Extract one part object to investigate
|
||||||
|
p = Part.objects.all().last()
|
||||||
|
|
||||||
|
# Initially some fields are not present
|
||||||
|
with self.assertRaises(AttributeError):
|
||||||
|
print(p.has_variants)
|
||||||
|
|
||||||
|
with self.assertRaises(AttributeError):
|
||||||
|
print(p.is_template)
|
||||||
|
|
||||||
|
def test_models_exist(self):
|
||||||
|
|
||||||
|
Part = self.new_state.apps.get_model('part', 'part')
|
||||||
|
|
||||||
|
self.assertEqual(Part.objects.count(), 5)
|
||||||
|
|
||||||
|
for part in Part.objects.all():
|
||||||
|
part.is_template = True
|
||||||
|
part.save()
|
||||||
|
part.is_template = False
|
||||||
|
part.save()
|
@ -3,7 +3,7 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import TestReport, ReportAsset
|
from .models import ReportSnippet, TestReport, ReportAsset
|
||||||
|
|
||||||
|
|
||||||
class ReportTemplateAdmin(admin.ModelAdmin):
|
class ReportTemplateAdmin(admin.ModelAdmin):
|
||||||
@ -11,10 +11,16 @@ class ReportTemplateAdmin(admin.ModelAdmin):
|
|||||||
list_display = ('name', 'description', 'template', 'filters', 'enabled')
|
list_display = ('name', 'description', 'template', 'filters', 'enabled')
|
||||||
|
|
||||||
|
|
||||||
|
class ReportSnippetAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
list_display = ('id', 'snippet', 'description')
|
||||||
|
|
||||||
|
|
||||||
class ReportAssetAdmin(admin.ModelAdmin):
|
class ReportAssetAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
list_display = ('asset', 'description')
|
list_display = ('id', 'asset', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(ReportSnippet, ReportSnippetAdmin)
|
||||||
admin.site.register(TestReport, ReportTemplateAdmin)
|
admin.site.register(TestReport, ReportTemplateAdmin)
|
||||||
admin.site.register(ReportAsset, ReportAssetAdmin)
|
admin.site.register(ReportAsset, ReportAssetAdmin)
|
||||||
|
@ -1,5 +1,92 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ReportConfig(AppConfig):
|
class ReportConfig(AppConfig):
|
||||||
name = 'report'
|
name = 'report'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
"""
|
||||||
|
This function is called whenever the report app is loaded
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.create_default_test_reports()
|
||||||
|
|
||||||
|
def create_default_test_reports(self):
|
||||||
|
"""
|
||||||
|
Create database entries for the default TestReport templates,
|
||||||
|
if they do not already exist
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .models import TestReport
|
||||||
|
except:
|
||||||
|
# Database is not ready yet
|
||||||
|
return
|
||||||
|
|
||||||
|
src_dir = os.path.join(
|
||||||
|
os.path.dirname(os.path.realpath(__file__)),
|
||||||
|
'templates',
|
||||||
|
'report',
|
||||||
|
)
|
||||||
|
|
||||||
|
dst_dir = os.path.join(
|
||||||
|
settings.MEDIA_ROOT,
|
||||||
|
'report',
|
||||||
|
'inventree', # Stored in secret directory!
|
||||||
|
'test',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(dst_dir):
|
||||||
|
logger.info(f"Creating missing directory: '{dst_dir}'")
|
||||||
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# List of test reports to copy across
|
||||||
|
reports = [
|
||||||
|
{
|
||||||
|
'file': 'inventree_test_report.html',
|
||||||
|
'name': 'InvenTree Test Report',
|
||||||
|
'description': 'Stock item test report',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for report in reports:
|
||||||
|
|
||||||
|
# Create destination file name
|
||||||
|
filename = os.path.join(
|
||||||
|
'report',
|
||||||
|
'inventree',
|
||||||
|
'test',
|
||||||
|
report['file']
|
||||||
|
)
|
||||||
|
|
||||||
|
src_file = os.path.join(src_dir, report['file'])
|
||||||
|
dst_file = os.path.join(settings.MEDIA_ROOT, filename)
|
||||||
|
|
||||||
|
if not os.path.exists(dst_file):
|
||||||
|
logger.info(f"Copying test report template '{dst_file}'")
|
||||||
|
shutil.copyfile(src_file, dst_file)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if a report matching the template already exists
|
||||||
|
if TestReport.objects.filter(template=filename).exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Creating new TestReport for '{report['name']}'")
|
||||||
|
|
||||||
|
TestReport.objects.create(
|
||||||
|
name=report['name'],
|
||||||
|
description=report['description'],
|
||||||
|
template=filename,
|
||||||
|
filters='',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
23
InvenTree/report/migrations/0006_reportsnippet.py
Normal file
23
InvenTree/report/migrations/0006_reportsnippet.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2021-02-04 04:37
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import report.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('report', '0005_auto_20210119_0815'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReportSnippet',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('snippet', models.FileField(help_text='Report snippet file', upload_to=report.models.rename_snippet, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])])),
|
||||||
|
('description', models.CharField(help_text='Snippet file description', max_length=250)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
20
InvenTree/report/migrations/0007_auto_20210204_1617.py
Normal file
20
InvenTree/report/migrations/0007_auto_20210204_1617.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2021-02-04 05:17
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import report.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('report', '0006_reportsnippet'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='testreport',
|
||||||
|
name='template',
|
||||||
|
field=models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template'),
|
||||||
|
),
|
||||||
|
]
|
18
InvenTree/report/migrations/0008_auto_20210204_2100.py
Normal file
18
InvenTree/report/migrations/0008_auto_20210204_2100.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2021-02-04 10:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('report', '0007_auto_20210204_1617'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='testreport',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(help_text='Template name', max_length=100, verbose_name='Name'),
|
||||||
|
),
|
||||||
|
]
|
@ -14,7 +14,6 @@ from django.db import models
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from django.core.validators import FileExtensionValidator
|
from django.core.validators import FileExtensionValidator
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
|
|
||||||
import stock.models
|
import stock.models
|
||||||
|
|
||||||
@ -29,32 +28,10 @@ except OSError as err:
|
|||||||
print("You may require some further system packages to be installed.")
|
print("You may require some further system packages to be installed.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Conditional import if LaTeX templating is enabled
|
|
||||||
if settings.LATEX_ENABLED:
|
|
||||||
try:
|
|
||||||
from django_tex.shortcuts import render_to_pdf
|
|
||||||
from django_tex.core import render_template_with_context
|
|
||||||
from django_tex.exceptions import TexError
|
|
||||||
except OSError as err:
|
|
||||||
print("OSError: {e}".format(e=err))
|
|
||||||
print("You may not have a working LaTeX toolchain installed?")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
from django.http import HttpResponse
|
|
||||||
|
|
||||||
|
|
||||||
class TexResponse(HttpResponse):
|
|
||||||
def __init__(self, content, filename=None):
|
|
||||||
super().__init__(content_type="application/txt")
|
|
||||||
self["Content-Disposition"] = 'filename="{}"'.format(filename)
|
|
||||||
self.write(content)
|
|
||||||
|
|
||||||
|
|
||||||
def rename_template(instance, filename):
|
def rename_template(instance, filename):
|
||||||
|
|
||||||
filename = os.path.basename(filename)
|
return instance.rename_file(filename)
|
||||||
|
|
||||||
return os.path.join('report', 'report_template', instance.getSubdir(), filename)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_stock_item_report_filters(filters):
|
def validate_stock_item_report_filters(filters):
|
||||||
@ -77,17 +54,27 @@ class WeasyprintReportMixin(WeasyTemplateResponseMixin):
|
|||||||
self.pdf_filename = kwargs.get('filename', 'report.pdf')
|
self.pdf_filename = kwargs.get('filename', 'report.pdf')
|
||||||
|
|
||||||
|
|
||||||
class ReportTemplateBase(models.Model):
|
class ReportBase(models.Model):
|
||||||
"""
|
"""
|
||||||
Reporting template model.
|
Base class for uploading html templates
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{n} - {d}".format(n=self.name, d=self.description)
|
return "{n} - {d}".format(n=self.name, d=self.description)
|
||||||
|
|
||||||
def getSubdir(self):
|
def getSubdir(self):
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
def rename_file(self, filename):
|
||||||
|
# Function for renaming uploaded file
|
||||||
|
|
||||||
|
filename = os.path.basename(filename)
|
||||||
|
|
||||||
|
return os.path.join('report', 'report_template', self.getSubdir(), filename)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extension(self):
|
def extension(self):
|
||||||
return os.path.splitext(self.template.name)[1].lower()
|
return os.path.splitext(self.template.name)[1].lower()
|
||||||
@ -96,15 +83,45 @@ class ReportTemplateBase(models.Model):
|
|||||||
def template_name(self):
|
def template_name(self):
|
||||||
"""
|
"""
|
||||||
Returns the file system path to the template file.
|
Returns the file system path to the template file.
|
||||||
Required for passing the file to an external process (e.g. LaTeX)
|
Required for passing the file to an external process
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template = os.path.join('report_template', self.getSubdir(), os.path.basename(self.template.name))
|
template = self.template.name
|
||||||
template = template.replace('/', os.path.sep)
|
template = template.replace('/', os.path.sep)
|
||||||
template = template.replace('\\', os.path.sep)
|
template = template.replace('\\', os.path.sep)
|
||||||
|
|
||||||
|
template = os.path.join(settings.MEDIA_ROOT, template)
|
||||||
|
|
||||||
return template
|
return template
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
blank=False, max_length=100,
|
||||||
|
verbose_name=_('Name'),
|
||||||
|
help_text=_('Template name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
template = models.FileField(
|
||||||
|
upload_to=rename_template,
|
||||||
|
verbose_name=_('Template'),
|
||||||
|
help_text=_("Report template file"),
|
||||||
|
validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])],
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.CharField(
|
||||||
|
max_length=250,
|
||||||
|
verbose_name=_('Description'),
|
||||||
|
help_text=_("Report template description")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportTemplateBase(ReportBase):
|
||||||
|
"""
|
||||||
|
Reporting template model.
|
||||||
|
|
||||||
|
Able to be passed context data
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
def get_context_data(self, request):
|
def get_context_data(self, request):
|
||||||
"""
|
"""
|
||||||
Supply context data to the template for rendering
|
Supply context data to the template for rendering
|
||||||
@ -116,56 +133,34 @@ class ReportTemplateBase(models.Model):
|
|||||||
"""
|
"""
|
||||||
Render the template to a PDF file.
|
Render the template to a PDF file.
|
||||||
|
|
||||||
Supported template formats:
|
Uses django-weasyprint plugin to render HTML template against Weasyprint
|
||||||
.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')
|
# TODO: Support custom filename generation!
|
||||||
|
# filename = kwargs.get('filename', 'report.pdf')
|
||||||
|
|
||||||
context = self.get_context_data(request)
|
context = self.get_context_data(request)
|
||||||
|
|
||||||
|
context['media'] = settings.MEDIA_ROOT
|
||||||
|
|
||||||
|
context['report_name'] = self.name
|
||||||
|
context['report_description'] = self.description
|
||||||
context['request'] = request
|
context['request'] = request
|
||||||
context['user'] = request.user
|
context['user'] = request.user
|
||||||
|
context['date'] = datetime.datetime.now().date()
|
||||||
context['datetime'] = datetime.datetime.now()
|
context['datetime'] = datetime.datetime.now()
|
||||||
|
|
||||||
if self.extension == '.tex':
|
# Render HTML template to PDF
|
||||||
# Render LaTeX template to PDF
|
wp = WeasyprintReportMixin(
|
||||||
if settings.LATEX_ENABLED:
|
request,
|
||||||
# Attempt to render to LaTeX template
|
self.template_name,
|
||||||
# If there is a rendering error, return the (partially rendered) template,
|
base_url=request.build_absolute_uri("/"),
|
||||||
# so at least we can debug what is going on
|
presentational_hints=True,
|
||||||
try:
|
**kwargs)
|
||||||
rendered = render_template_with_context(self.template_name, context)
|
|
||||||
return render_to_pdf(request, self.template_name, context, filename=filename)
|
|
||||||
except TexError:
|
|
||||||
return TexResponse(rendered, filename="error.tex")
|
|
||||||
else:
|
|
||||||
raise 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(
|
return wp.render_to_response(
|
||||||
blank=False, max_length=100,
|
context,
|
||||||
verbose_name=_('Name'),
|
**kwargs)
|
||||||
help_text=_('Template name'),
|
|
||||||
unique=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
template = models.FileField(
|
|
||||||
upload_to=rename_template,
|
|
||||||
verbose_name=_('Template'),
|
|
||||||
help_text=_("Report template file"),
|
|
||||||
validators=[FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])],
|
|
||||||
)
|
|
||||||
|
|
||||||
description = models.CharField(
|
|
||||||
max_length=250,
|
|
||||||
verbose_name=_('Description'),
|
|
||||||
help_text=_("Report template description")
|
|
||||||
)
|
|
||||||
|
|
||||||
enabled = models.BooleanField(
|
enabled = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
@ -221,6 +216,30 @@ class TestReport(ReportTemplateBase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rename_snippet(instance, filename):
|
||||||
|
|
||||||
|
filename = os.path.basename(filename)
|
||||||
|
|
||||||
|
return os.path.join('report', 'snippets', filename)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportSnippet(models.Model):
|
||||||
|
"""
|
||||||
|
Report template 'snippet' which can be used to make templates
|
||||||
|
that can then be included in other reports.
|
||||||
|
|
||||||
|
Useful for 'common' template actions, sub-templates, etc
|
||||||
|
"""
|
||||||
|
|
||||||
|
snippet = models.FileField(
|
||||||
|
upload_to=rename_snippet,
|
||||||
|
help_text=_('Report snippet file'),
|
||||||
|
validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])],
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.CharField(max_length=250, help_text=_("Snippet file description"))
|
||||||
|
|
||||||
|
|
||||||
def rename_asset(instance, filename):
|
def rename_asset(instance, filename):
|
||||||
|
|
||||||
filename = os.path.basename(filename)
|
filename = os.path.basename(filename)
|
||||||
|
103
InvenTree/report/templates/report/inventree_report_base.html
Normal file
103
InvenTree/report/templates/report/inventree_report_base.html
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
{% load report %}
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
{% block page_size %}
|
||||||
|
size: A4;
|
||||||
|
{% endblock %}
|
||||||
|
{% block page_margin %}
|
||||||
|
margin: 2cm;
|
||||||
|
{% endblock %}
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 75%;
|
||||||
|
|
||||||
|
@top-left {
|
||||||
|
{% block top_left %}
|
||||||
|
{% endblock %}
|
||||||
|
}
|
||||||
|
|
||||||
|
@top-center {
|
||||||
|
{% block top_center %}
|
||||||
|
{% endblock %}
|
||||||
|
}
|
||||||
|
|
||||||
|
@top-right {
|
||||||
|
{% block top_right %}
|
||||||
|
{% endblock %}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bottom-left {
|
||||||
|
{% block bottom_left %}
|
||||||
|
{% endblock %}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bottom-center {
|
||||||
|
{% block bottom_center %}
|
||||||
|
{% endblock %}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bottom-right {
|
||||||
|
{% block bottom_right %}
|
||||||
|
content: "Page " counter(page) " of " counter(pages);
|
||||||
|
{% endblock %}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
{% block header_style %}
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: -2.5cm;
|
||||||
|
{% endblock %}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
{% block content_style %}
|
||||||
|
width: 100%;
|
||||||
|
page-break-inside: auto;
|
||||||
|
position: relative;
|
||||||
|
{% endblock %}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
{% block footer_style %}
|
||||||
|
bottom: 0px;
|
||||||
|
left: 0px;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: -20mm;
|
||||||
|
{% endblock %}
|
||||||
|
}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
/* User defined styles go here */
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class='header'>
|
||||||
|
{% block header_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='content'>
|
||||||
|
{% block page_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='footer'>
|
||||||
|
{% block footer_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
112
InvenTree/report/templates/report/inventree_test_report.html
Normal file
112
InvenTree/report/templates/report/inventree_test_report.html
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
{% extends "report/inventree_report_base.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
{% load report %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
.test-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% block bottom_left %}
|
||||||
|
content: "{{ date.isoformat }}";
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block bottom_center %}
|
||||||
|
content: "InvenTree v{% inventree_version %}";
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block top_center %}
|
||||||
|
content: "{% trans 'Stock Item Test Report' %}";
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
.test-row {
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-pass {
|
||||||
|
color: #5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-fail {
|
||||||
|
color: #F55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-left {
|
||||||
|
display: inline-block;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-right {
|
||||||
|
display: inline;
|
||||||
|
align-content: right;
|
||||||
|
align-items: right;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_content %}
|
||||||
|
|
||||||
|
<div class='container'>
|
||||||
|
<div class='text-left'>
|
||||||
|
<h2>
|
||||||
|
{{ part.full_name }}
|
||||||
|
</h2>
|
||||||
|
<p>{{ part.description }}</p>
|
||||||
|
<p><i>{{ stock_item.location }}</i></p>
|
||||||
|
<p><i>Stock Item ID: {{ stock_item.pk }}</i></p>
|
||||||
|
</div>
|
||||||
|
<div class='img-right'>
|
||||||
|
<img src="{% part_image part %}">
|
||||||
|
<hr>
|
||||||
|
<h4>
|
||||||
|
{% if stock_item.is_serialized %}
|
||||||
|
{% trans "Serial Number" %}: {{ stock_item.serial }}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Quantity" %}: {% decimal stock_item.quantity %}
|
||||||
|
{% endif %}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{% trans "Test Results" %}</h3>
|
||||||
|
|
||||||
|
<table class='table test-table'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Test" %}</th>
|
||||||
|
<th>{% trans "Result" %}</th>
|
||||||
|
<th>{% trans "Value" %}</th>
|
||||||
|
<th>{% trans "User" %}</th>
|
||||||
|
<th>{% trans "Date" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan='5'><hr></td>
|
||||||
|
</tr>
|
||||||
|
{% for test in result_list %}
|
||||||
|
<tr class='test-row'>
|
||||||
|
<td>{{ test.test }}</td>
|
||||||
|
{% if test.result %}
|
||||||
|
<td class='test-pass'>{% trans "Pass" %}</td>
|
||||||
|
{% else %}
|
||||||
|
<td class='test-fail'>{% trans "Fail" %}</td>
|
||||||
|
{% endif %}
|
||||||
|
<td>{{ test.value }}</td>
|
||||||
|
<td>{{ test.user.username }}</td>
|
||||||
|
<td>{{ test.date.date.isoformat }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endblock %}
|
52
InvenTree/report/templatetags/report.py
Normal file
52
InvenTree/report/templatetags/report.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
Custom template tags for report generation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from part.models import Part
|
||||||
|
from stock.models import StockItem
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def asset(filename):
|
||||||
|
"""
|
||||||
|
Return fully-qualified path for an upload report asset file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
path = os.path.join(settings.MEDIA_ROOT, 'report', 'assets', filename)
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
|
||||||
|
return f"file://{path}"
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def part_image(part):
|
||||||
|
"""
|
||||||
|
Return a fully-qualified path for a part image
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(part) is Part:
|
||||||
|
img = part.image.name
|
||||||
|
|
||||||
|
elif type(part) is StockItem:
|
||||||
|
img = part.part.image.name
|
||||||
|
|
||||||
|
else:
|
||||||
|
img = ''
|
||||||
|
|
||||||
|
path = os.path.join(settings.MEDIA_ROOT, img)
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
|
||||||
|
if not os.path.exists(path) or not os.path.isfile(path):
|
||||||
|
# Image does not exist
|
||||||
|
# Return the 'blank' image
|
||||||
|
path = os.path.join(settings.STATIC_ROOT, 'img', 'blank_image.png')
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
|
||||||
|
return f"file://{path}"
|
@ -13,6 +13,8 @@ def update_tree(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('stock', '0011_auto_20190908_0404'),
|
('stock', '0011_auto_20190908_0404'),
|
||||||
]
|
]
|
||||||
|
@ -12,6 +12,8 @@ def update_stock_item_tree(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
atomic = False
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('stock', '0021_auto_20200215_2232'),
|
('stock', '0021_auto_20200215_2232'),
|
||||||
]
|
]
|
||||||
|
@ -120,7 +120,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class='btn-group action-buttons' role='group'>
|
<div class='btn-group action-buttons' role='group'>
|
||||||
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
|
||||||
{% if barcodes %}
|
{% if barcodes %}
|
||||||
<!-- Barcode actions menu -->
|
<!-- Barcode actions menu -->
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
@ -139,19 +139,15 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Document / label menu -->
|
<!-- Document / label menu -->
|
||||||
{% if item.has_labels or item.has_test_reports %}
|
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='document-options' title='{% trans "Printing actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-print'></span> <span class='caret'></span></button>
|
<button id='document-options' title='{% trans "Printing actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-print'></span> <span class='caret'></span></button>
|
||||||
<ul class='dropdown-menu' role='menu'>
|
<ul class='dropdown-menu' role='menu'>
|
||||||
{% if item.has_labels %}
|
|
||||||
<li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
<li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||||
{% endif %}
|
{% if test_report_enabled %}
|
||||||
{% if item.has_test_reports %}
|
|
||||||
<li><a href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
|
<li><a href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<!-- Stock adjustment menu -->
|
<!-- Stock adjustment menu -->
|
||||||
<!-- Check permissions and owner -->
|
<!-- Check permissions and owner -->
|
||||||
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}
|
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}
|
||||||
|
@ -36,8 +36,7 @@
|
|||||||
<span class='fas fa-plus-circle icon-green'/>
|
<span class='fas fa-plus-circle icon-green'/>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
|
||||||
{% if barcodes %}
|
{% if barcodes %}
|
||||||
<!-- Barcode actions menu -->
|
<!-- Barcode actions menu -->
|
||||||
{% if location %}
|
{% if location %}
|
||||||
|
22
InvenTree/templates/InvenTree/settings/report.html
Normal file
22
InvenTree/templates/InvenTree/settings/report.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{% extends "InvenTree/settings/settings.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% block tabs %}
|
||||||
|
{% include "InvenTree/settings/tabs.html" with tab='report' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block subtitle %}
|
||||||
|
{% trans "Report Settings" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block settings %}
|
||||||
|
|
||||||
|
<table class='table table-striped table-condensed'>
|
||||||
|
{% include "InvenTree/settings/header.html" %}
|
||||||
|
<tbody>
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -15,6 +15,9 @@
|
|||||||
<li {% if tab == 'global' %} class='active' {% endif %}>
|
<li {% if tab == 'global' %} class='active' {% endif %}>
|
||||||
<a href='{% url "settings-global" %}'><span class='fas fa-globe'></span> {% trans "Global" %}</a>
|
<a href='{% url "settings-global" %}'><span class='fas fa-globe'></span> {% trans "Global" %}</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li {% if tab == 'report' %} class='active' {% endif %}>
|
||||||
|
<a href='{% url "settings-report" %}'><span class='fas fa-file-pdf'></span> {% trans "Report" %}</a>
|
||||||
|
</li>
|
||||||
<li{% ifequal tab 'category' %} class='active'{% endifequal %}>
|
<li{% ifequal tab 'category' %} class='active'{% endifequal %}>
|
||||||
<a href="{% url 'settings-category' %}"><span class='fa fa-sitemap'></span> {% trans "Categories" %}</a>
|
<a href="{% url 'settings-category' %}"><span class='fa fa-sitemap'></span> {% trans "Categories" %}</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
||||||
|
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
@ -10,6 +10,15 @@ function selectTestReport(reports, items, options={}) {
|
|||||||
* (via AJAX) from the server.
|
* (via AJAX) from the server.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// If there is only a single report available, just print!
|
||||||
|
if (reports.length == 1) {
|
||||||
|
if (options.success) {
|
||||||
|
options.success(reports[0].pk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var modal = options.modal || '#modal-form';
|
var modal = options.modal || '#modal-form';
|
||||||
|
|
||||||
var report_list = makeOptionsList(
|
var report_list = makeOptionsList(
|
||||||
|
@ -38,7 +38,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<ul class='dropdown-menu'>
|
<ul class='dropdown-menu'>
|
||||||
<li><a href='#' id='multi-item-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
|
<li><a href='#' id='multi-item-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
|
||||||
|
{% if test_report_enabled %}
|
||||||
<li><a href='#' id='multi-item-print-test-report' title='{% trans "Print test reports" %}'><span class='fas fa-file-pdf'></span> {% trans "Print test reports" %}</a></li>
|
<li><a href='#' id='multi-item-print-test-report' title='{% trans "Print test reports" %}'><span class='fas fa-file-pdf'></span> {% trans "Print test reports" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% if roles.stock.change or roles.stock.delete %}
|
{% if roles.stock.change or roles.stock.delete %}
|
||||||
|
@ -118,6 +118,7 @@ class RuleSet(models.Model):
|
|||||||
'label_stockitemlabel',
|
'label_stockitemlabel',
|
||||||
'label_stocklocationlabel',
|
'label_stocklocationlabel',
|
||||||
'report_reportasset',
|
'report_reportasset',
|
||||||
|
'report_reportsnippet',
|
||||||
'report_testreport',
|
'report_testreport',
|
||||||
'part_partstar',
|
'part_partstar',
|
||||||
'users_owner',
|
'users_owner',
|
||||||
|
@ -23,12 +23,13 @@ coverage==5.3 # Unit test coverage
|
|||||||
coveralls==2.1.2 # Coveralls linking (for Travis)
|
coveralls==2.1.2 # 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
|
django-weasyprint==1.0.1 # HTML PDF export
|
||||||
django-debug-toolbar==2.2 # Debug / profiling toolbar
|
django-debug-toolbar==2.2 # Debug / profiling toolbar
|
||||||
django-admin-shell==0.1.2 # Python shell for the admin interface
|
django-admin-shell==0.1.2 # Python shell for the admin interface
|
||||||
django-money==1.1 # Django app for currency management
|
django-money==1.1 # Django app for currency management
|
||||||
certifi # Certifi is (most likely) installed through one of the requirements above
|
certifi # Certifi is (most likely) installed through one of the requirements above
|
||||||
django-error-report==0.2.0 # Error report viewer for the admin interface
|
django-error-report==0.2.0 # Error report viewer for the admin interface
|
||||||
|
django-test-migrations==1.1.0 # Unit testing for database migrations
|
||||||
|
django-migration-linter==2.5.0 # Linting checks for database migrations
|
||||||
|
|
||||||
inventree # Install the latest version of the InvenTree API python library
|
inventree # Install the latest version of the InvenTree API python library
|
||||||
|
@ -9,6 +9,8 @@ ignore =
|
|||||||
C901,
|
C901,
|
||||||
# - N802 - function name should be lowercase (In the future, we should conform to this!)
|
# - N802 - function name should be lowercase (In the future, we should conform to this!)
|
||||||
N802,
|
N802,
|
||||||
|
# - N806 - variable should be lowercase
|
||||||
|
N806,
|
||||||
N812,
|
N812,
|
||||||
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,*ci_*.py*
|
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,*ci_*.py*
|
||||||
max-complexity = 20
|
max-complexity = 20
|
||||||
|
Loading…
Reference in New Issue
Block a user