Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2021-02-05 00:05:45 +11:00
commit c6d9802010
41 changed files with 1131 additions and 202 deletions

View File

@ -1,8 +1,6 @@
[run]
source = ./InvenTree
omit =
# Do not run coverage on migration files
*/migrations/*
InvenTree/manage.py
InvenTree/setup.py
InvenTree/InvenTree/middleware.py

View File

@ -48,6 +48,10 @@ script:
- rm inventree_default_db.sqlite3
- invoke migrate
- 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:
- coveralls

View File

@ -492,3 +492,72 @@ def addUserPermissions(user, permissions):
for permission in permissions:
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

View File

@ -208,11 +208,11 @@ INSTALLED_APPS = [
'mptt', # Modified Preorder Tree Traversal
'markdownx', # Markdown editing
'markdownify', # Markdown template rendering
'django_tex', # LaTeX output
'django_admin_shell', # Python shell for the admin interface
'djmoney', # django-money integration
'djmoney.contrib.exchange', # django-money exchange rates
'error_report', # Error reporting in the admin interface
'django_migration_linter', # Linting checking for migration files
]
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 = {
@ -485,22 +477,6 @@ DATE_INPUT_FORMATS = [
"%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_TEMPLATE_PACK = 'bootstrap3'

View File

@ -79,6 +79,7 @@ settings_urls = [
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'^report/?', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),
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'^stock/?', SettingsView.as_view(template_name='InvenTree/settings/stock.html'), name='settings-stock'),

View File

@ -17,6 +17,8 @@ def nupdate_tree(apps, schema_editor):
class Migration(migrations.Migration):
atomic = False
dependencies = [
('build', '0012_build_sales_order'),
]

View File

@ -9,7 +9,7 @@ def add_default_reference(apps, schema_editor):
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
@ -31,6 +31,8 @@ def reverse_default_reference(apps, schema_editor):
class Migration(migrations.Migration):
atomic = False
dependencies = [
('build', '0017_auto_20200426_0612'),
]

View 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))

View File

@ -174,6 +174,13 @@ class InvenTreeSetting(models.Model):
'validator': bool,
},
'REPORT_ENABLE_TEST_REPORT': {
'name': _('Test Reports'),
'description': _('Enable generation of test reports'),
'default': True,
'validator': bool,
},
'STOCK_ENABLE_EXPIRY': {
'name': _('Stock Expiry'),
'description': _('Enable stock expiry functionality'),

View File

@ -1,15 +1,21 @@
# Generated by Django 2.2.10 on 2020-04-13 06:42
import sys
import os
from rapidfuzz import fuzz
from django.db import migrations, connection
from company.models import Company, SupplierPart
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():
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):
@ -21,25 +27,29 @@ def reverse_association(apps, schema_editor):
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
# This crucial otherwise the unit test suite fails!
if SupplierPart.objects.count() == 0:
if len(supplier_parts) == 0:
return
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))
cursor = connection.cursor()
print(f"Checking SupplierPart [{supplier_part_id}]:")
# 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
row = response.fetchone()
row = cursor.fetchone()
if len(row) > 0:
try:
@ -54,15 +64,15 @@ def reverse_association(apps, schema_editor):
print(" - Manufacturer ID: [{id}]".format(id=manufacturer_id))
# 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]
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):
"""
@ -100,10 +110,14 @@ def associate_manufacturers(apps, schema_editor):
return row[0]
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
# This crucial otherwise the unit test suite fails!
if SupplierPart.objects.count() == 0:
if len(supplier_parts) == 0:
return
# Link a 'manufacturer_name' to a 'Company'
@ -112,50 +126,63 @@ def associate_manufacturers(apps, schema_editor):
# Map company names to company objects
companies = {}
for company in Company.objects.all():
companies[company.name] = company
# Iterate through each company object
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 """
# Matches a company name directly
if name in companies.keys():
print(" - Part[{pk}]: '{n}' maps to existing manufacturer".format(pk=part.pk, n=name))
part.manufacturer = companies[name]
part.save()
print(" - Part[{pk}]: '{n}' maps to existing manufacturer".format(pk=part_id, n=name))
manufacturer_id = companies[name]
query = f"update part_supplierpart set manufacturer_id={manufacturer_id} where id={part_id};"
result = cursor.execute(query)
return True
# Have we already mapped this
if name in links.keys():
print(" - Part[{pk}]: Mapped '{n}' - '{c}'".format(pk=part.pk, n=name, c=links[name].name))
part.manufacturer = links[name]
part.save()
print(" - Part[{pk}]: Mapped '{n}' - manufacturer <{c}>".format(pk=part_id, n=name, c=links[name]))
manufacturer_id = links[name]
query = f"update part_supplierpart set manufacturer_id={manufacturer_id} where id={part_id};"
result = cursor.execute(query)
return True
# Mapping not possible
return False
def create_manufacturer(part, input_name, company_name):
def create_manufacturer(part_id, input_name, company_name):
""" 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
# Save the company BEFORE we associate the part, otherwise the PK does not exist
company.save()
manufacturer = Company.objects.create(
name=company_name,
description=company_name,
is_manufacturer=True
)
# Map both names to the same company
links[input_name] = company
links[company_name] = company
links[input_name] = manufacturer.pk
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
part.manufacturer = company
part.save()
# Update SupplierPart object in the database
cursor.execute(f"update part_supplierpart set manufacturer_id={manufacturer.pk} where id={part_id};")
def find_matches(text, threshold=65):
"""
@ -178,17 +205,19 @@ def associate_manufacturers(apps, schema_editor):
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
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
# Can be linked to an existing manufacturer
if link_part(part, name):
if link_part(part_id, name):
return
# Find a list of potential matches
@ -197,23 +226,31 @@ def associate_manufacturers(apps, schema_editor):
clear()
# Present a list of options
print("----------------------------------")
print("Checking part [{pk}] ({idx} of {total})".format(pk=part.pk, idx=idx+1, total=total))
print("Manufacturer name: '{n}'".format(n=name))
print("----------------------------------")
print("Select an option from the list below:")
if not TESTING:
print("----------------------------------")
print("Checking part [{pk}] ({idx} of {total})".format(pk=part_id, idx=idx+1, total=total))
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("")
print("0) - Create new manufacturer '{n}'".format(n=name))
print("")
for i, m in enumerate(matches[:10]):
print("{i}) - Use manufacturer '{opt}'".format(i=i+1, opt=m))
for i, m in enumerate(matches[:10]):
print("{i}) - Use manufacturer '{opt}'".format(i=i+1, opt=m))
print("")
print("OR - Type a new custom manufacturer name")
print("")
print("OR - Type a new custom manufacturer name")
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
try:
@ -222,7 +259,7 @@ def associate_manufacturers(apps, schema_editor):
# Option 0) is to create a new manufacturer with the current name
if n == 0:
create_manufacturer(part, name, name)
create_manufacturer(part_id, name, name)
return
# Options 1) - n) select an existing manufacturer
@ -232,21 +269,19 @@ def associate_manufacturers(apps, schema_editor):
if n < len(matches):
# Get the company which matches the selected options
company_name = matches[n]
company = companies[company_name]
company_id = companies[company_name]
# Ensure the company is designated as a manufacturer
company.is_manufacturer = True
company.save()
cursor.execute(f"update company_company set is_manufacturer=true where id={company_id};")
# Link the company to the part
part.manufacturer = company
part.save()
cursor.execute(f"update part_supplierpart set manufacturer_id={company_id} where id={part_id};")
# Link the name to the company
links[name] = company
links[company_name] = company
links[name] = company_id
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
else:
@ -270,43 +305,52 @@ def associate_manufacturers(apps, schema_editor):
# No match, create a new manufacturer
else:
create_manufacturer(part, name, response)
create_manufacturer(part_id, name, response)
return
clear()
print("")
clear()
print("---------------------------------------")
print("The SupplierPart model needs to be migrated,")
print("as the new 'manufacturer' field maps to a 'Company' reference.")
print("The existing 'manufacturer_name' field will be used to match")
print("against possible companies.")
print("This process requires user input.")
print("")
print("Note: This process MUST be completed to migrate the database.")
print("---------------------------------------")
print("")
if not TESTING:
print("---------------------------------------")
print("The SupplierPart model needs to be migrated,")
print("as the new 'manufacturer' field maps to a 'Company' reference.")
print("The existing 'manufacturer_name' field will be used to match")
print("against possible companies.")
print("This process requires user input.")
print("")
print("Note: This process MUST be completed to migrate the database.")
print("---------------------------------------")
print("")
input("Press <ENTER> to continue.")
input("Press <ENTER> to continue.")
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
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:
print(" - Part '{p}' already has a manufacturer associated (skipping)".format(p=part))
if manufacturer_id is not None:
print(f" - SupplierPart <{pk}> already has a manufacturer associated (skipping)")
continue
map_part_to_manufacturer(part, idx, part_count)
map_part_to_manufacturer(pk, index, part_count)
print("Done!")
class Migration(migrations.Migration):
atomic = False
dependencies = [
('company', '0018_supplierpart_manufacturer'),
]

View File

@ -1,6 +1,14 @@
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):
Company = apps.get_model('company', 'Company')
for company in Company.objects.all():
@ -11,6 +19,8 @@ def make_empty_email_field_null(apps, schema_editor):
class Migration(migrations.Migration):
atomic = False
dependencies = [
('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'),
),
# 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
migrations.AlterField(
model_name='company',

View File

@ -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 code in codes_in_use:
response = cursor.execute(f"SELECT id, suffix from common_currency where suffix='{code}';")
row = response.fetchone()
row = cursor.fetchone()
if row is not None:
# A match exists!
@ -138,6 +138,8 @@ def reverse_currencies(apps, schema_editor):
class Migration(migrations.Migration):
atomic = False
dependencies = [
('company', '0025_auto_20201110_1001'),
]

View 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)

View File

@ -107,19 +107,6 @@ static_root: '../inventree_static'
# If unspecified, the local user's temp directory will be used
#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
#authentication_backends:
# - 'django.contrib.auth.backends.ModelBackend'

View File

@ -12,6 +12,8 @@ def update_tree(apps, schema_editor):
class Migration(migrations.Migration):
atomic = False
dependencies = [
('part', '0019_auto_20190908_0404'),
]

View File

@ -18,6 +18,8 @@ def create_thumbnails(apps, schema_editor):
class Migration(migrations.Migration):
atomic = False
dependencies = [
('part', '0033_auto_20200404_0445'),
]

View File

@ -16,6 +16,8 @@ def nupdate_tree(apps, schema_editor):
class Migration(migrations.Migration):
atomic = False
dependencies = [
('part', '0038_auto_20200513_0016'),
]

View File

@ -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 code in codes_in_use:
response = cursor.execute(f"SELECT id, suffix from common_currency where suffix='{code}';")
row = response.fetchone()
row = cursor.fetchone()
if row is not None:
# A match exists!
@ -138,6 +138,8 @@ def reverse_currencies(apps, schema_editor):
class Migration(migrations.Migration):
atomic = False
dependencies = [
('part', '0055_auto_20201110_1001'),
]

View File

@ -44,7 +44,6 @@
<span id='part-star-icon' class='fas fa-star {% if starred %}icon-yellow{% endif %}'/>
</button>
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group'>

View 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()

View File

@ -3,7 +3,7 @@ from __future__ import unicode_literals
from django.contrib import admin
from .models import TestReport, ReportAsset
from .models import ReportSnippet, TestReport, ReportAsset
class ReportTemplateAdmin(admin.ModelAdmin):
@ -11,10 +11,16 @@ class ReportTemplateAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'template', 'filters', 'enabled')
class ReportSnippetAdmin(admin.ModelAdmin):
list_display = ('id', 'snippet', 'description')
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(ReportAsset, ReportAssetAdmin)

View File

@ -1,5 +1,92 @@
import os
import shutil
import logging
from django.apps import AppConfig
from django.conf import settings
logger = logging.getLogger(__name__)
class ReportConfig(AppConfig):
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

View 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)),
],
),
]

View 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'),
),
]

View 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'),
),
]

View File

@ -14,7 +14,6 @@ from django.db import models
from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.core.exceptions import ValidationError
import stock.models
@ -29,32 +28,10 @@ except OSError as err:
print("You may require some further system packages to be installed.")
sys.exit(1)
# Conditional import if LaTeX templating is enabled
if settings.LATEX_ENABLED:
try:
from django_tex.shortcuts import render_to_pdf
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):
filename = os.path.basename(filename)
return os.path.join('report', 'report_template', instance.getSubdir(), filename)
return instance.rename_file(filename)
def validate_stock_item_report_filters(filters):
@ -77,17 +54,27 @@ class WeasyprintReportMixin(WeasyTemplateResponseMixin):
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):
return "{n} - {d}".format(n=self.name, d=self.description)
def getSubdir(self):
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
def extension(self):
return os.path.splitext(self.template.name)[1].lower()
@ -96,15 +83,45 @@ class ReportTemplateBase(models.Model):
def template_name(self):
"""
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 = os.path.join(settings.MEDIA_ROOT, 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):
"""
Supply context data to the template for rendering
@ -116,56 +133,34 @@ class ReportTemplateBase(models.Model):
"""
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
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['media'] = settings.MEDIA_ROOT
context['report_name'] = self.name
context['report_description'] = self.description
context['request'] = request
context['user'] = request.user
context['date'] = datetime.datetime.now().date()
context['datetime'] = datetime.datetime.now()
if self.extension == '.tex':
# Render LaTeX template to PDF
if settings.LATEX_ENABLED:
# Attempt to render to LaTeX template
# If there is a rendering error, return the (partially rendered) template,
# so at least we can debug what is going on
try:
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)
# Render HTML template to PDF
wp = WeasyprintReportMixin(
request,
self.template_name,
base_url=request.build_absolute_uri("/"),
presentational_hints=True,
**kwargs)
name = models.CharField(
blank=False, max_length=100,
verbose_name=_('Name'),
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")
)
return wp.render_to_response(
context,
**kwargs)
enabled = models.BooleanField(
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):
filename = os.path.basename(filename)

View 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>

View 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 %}

View 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}"

View File

@ -13,6 +13,8 @@ def update_tree(apps, schema_editor):
class Migration(migrations.Migration):
atomic = False
dependencies = [
('stock', '0011_auto_20190908_0404'),
]

View File

@ -12,6 +12,8 @@ def update_stock_item_tree(apps, schema_editor):
class Migration(migrations.Migration):
atomic = False
dependencies = [
('stock', '0021_auto_20200215_2232'),
]

View File

@ -120,7 +120,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
</div>
<div class='btn-group action-buttons' role='group'>
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group'>
@ -139,19 +139,15 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
</div>
{% endif %}
<!-- Document / label menu -->
{% if item.has_labels or item.has_test_reports %}
<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>
<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>
{% endif %}
{% if item.has_test_reports %}
{% if test_report_enabled %}
<li><a href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
{% endif %}
</ul>
</div>
{% endif %}
<!-- Stock adjustment menu -->
<!-- Check permissions and owner -->
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}

View File

@ -36,8 +36,7 @@
<span class='fas fa-plus-circle icon-green'/>
</button>
{% endif %}
{% endif %}
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% endif %}
{% if barcodes %}
<!-- Barcode actions menu -->
{% if location %}

View 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 %}

View File

@ -15,6 +15,9 @@
<li {% if tab == 'global' %} class='active' {% endif %}>
<a href='{% url "settings-global" %}'><span class='fas fa-globe'></span> {% trans "Global" %}</a>
</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 %}>
<a href="{% url 'settings-category' %}"><span class='fa fa-sitemap'></span> {% trans "Categories" %}</a>
</li>

View File

@ -3,6 +3,7 @@
{% load inventree_extras %}
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
<!DOCTYPE html>
<html lang="en">

View File

@ -10,6 +10,15 @@ function selectTestReport(reports, items, options={}) {
* (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 report_list = makeOptionsList(

View File

@ -38,7 +38,9 @@
</button>
<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>
{% 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>
{% endif %}
</ul>
</div>
{% if roles.stock.change or roles.stock.delete %}

View File

@ -118,6 +118,7 @@ class RuleSet(models.Model):
'label_stockitemlabel',
'label_stocklocationlabel',
'report_reportasset',
'report_reportsnippet',
'report_testreport',
'part_partstar',
'users_owner',

View File

@ -23,12 +23,13 @@ coverage==5.3 # Unit test coverage
coveralls==2.1.2 # Coveralls linking (for Travis)
rapidfuzz==0.7.6 # Fuzzy string matching
django-stdimage==5.1.1 # Advanced ImageField management
django-tex==1.1.7 # LaTeX PDF export
django-weasyprint==1.0.1 # HTML PDF export
django-debug-toolbar==2.2 # Debug / profiling toolbar
django-admin-shell==0.1.2 # Python shell for the admin interface
django-money==1.1 # Django app for currency management
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

View File

@ -9,6 +9,8 @@ ignore =
C901,
# - N802 - function name should be lowercase (In the future, we should conform to this!)
N802,
# - N806 - variable should be lowercase
N806,
N812,
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,*ci_*.py*
max-complexity = 20