diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..13a8d06 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +source = + invoice_generator diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c4fd8fb --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +language: python +cache: pip +sudo: false +python: + - "3.6" + - "3.5" + - "nightly" + - "pypy3" +install: + - pip install codecov coverage +matrix: + allow_failures: + - python: "nightly" + - python: "pypy3" +script: + - pip3 install -e ".[testing]" + - coverage run -m unittest discover -s tests +after_success: + - codecov diff --git a/invoice_generator/models.py b/invoice_generator/models.py index 37b1974..a96fe18 100644 --- a/invoice_generator/models.py +++ b/invoice_generator/models.py @@ -105,7 +105,7 @@ class Invoice(object): def __init__(self, order_data: Order, vendor: Vendor, billing_address: Address, - *items: OrderItem): + items: OrderItem): self.order = order_data self.vendor = vendor self.billing_address = billing_address diff --git a/invoice_generator/templates/index.html b/invoice_generator/templates/index.html index 1824738..b601327 100644 --- a/invoice_generator/templates/index.html +++ b/invoice_generator/templates/index.html @@ -1,179 +1,180 @@ {% load i18n %} - - - - - -
- - - - + @bottom-right { + white-space: pre; + text-align: left; + font-size: 8pt; + content: "{% trans "Management Board" %}" "\a" + "{{ executive.name }}" "\a" + "{{ executive.text }}" "\a" + "\a\a\a" + } + } + + + +
+
- {{ vendor.address.name }}, - {{ vendor.address.street }}, - {{ vendor.address.postcode }} {{ vendor.address.city }} -
+ + + - - - + + + - - - + + + - - - -
+ {{ vendor.address.name }}, + {{ vendor.address.street }}, + {{ vendor.address.postcode }} {{ vendor.address.city }} +
{{ billing_address.name }}
{{ billing_address.name }}
{{ billing_address.street }}
{{ billing_address.street }}
- {{ billing_address.postcode }} - {{ billing_address.city }} -
-
+ + + {{ billing_address.postcode }} + {{ billing_address.city }} + + + + -
- - - +
+
- - {% blocktrans with order_id=order.order_id %}Order ID: {{ order_id }}{% endblocktrans %} - -
+ + - - - - {% if order.shipping_date_range %} - - {% endif %} - - -
+ {% blocktrans with order_id=order.order_id %}Order ID: {{ order_id }}{% endblocktrans %} + - {% blocktrans with customer_id=order.customer_id %}Customer ID: {{ customer_id }}{% endblocktrans %} -
{% blocktrans %}The order will be shipped upon receipt of payment.{% endblocktrans %} - {% blocktrans with date=order.date|date %}Date of Invoice: {{ date }}{% endblocktrans %} -
-
+ + {% blocktrans with customer_id=order.customer_id %}Customer ID: {{ customer_id }}{% endblocktrans %} + + + + {% if order.shipping_date_range %} + {% blocktrans %}The order will be shipped upon receipt of payment.{% endblocktrans %} + {% endif %} + + {% blocktrans with date=order.date|date %}Date of Invoice: {{ date }}{% endblocktrans %} + + + + -
- - - - - - - - - - - - - {% for item in invoice.items %} - - - - - - - - - {% endfor %} - - - - {% if order.total_discounted %} - - - - - - - {% endif %} - {% if order.total_shipping_net %} - - - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - -
{% blocktrans %}REF{% endblocktrans %}{% blocktrans %}Description{% endblocktrans %}{% blocktrans %}Quantity{% endblocktrans %}{% blocktrans %}Price per Unit{% endblocktrans %}{% blocktrans %}Tax Rate{% endblocktrans %}{% blocktrans %}Net Price{% endblocktrans %}
{{ item.reference }}{{ item.text|safe }}{{ item.quantity }}{{ item.net_price|floatformat:2 }} {{ currency }}{{ item.tax_rate }} %{{ item.total_net|floatformat:2 }} {{ currency }}
{% blocktrans %}Total discounted{% endblocktrans %}{{ order.total_discounted|floatformat:2 }} {{ currency }}
{% blocktrans %}Shipping{% endblocktrans %}{{ order.total_shipping_net|floatformat:2 }} {{ currency }}
{% blocktrans %}Net total{% endblocktrans %}{{ order.total_net|floatformat:2 }} {{ currency }}
{% blocktrans with tax=order.tax_rate %}VAT ({{ tax }}%){% endblocktrans %}{{ order.total_tax|floatformat:2 }} {{ currency }}
{% blocktrans %}Gross total{% endblocktrans %}{{ order.total_gross|floatformat:2 }} {{ currency }}
-
- -
-

{% blocktrans %}Thank you for your order.{% endblocktrans %}

+
+ + + + + + + + + + + + + {% for item in invoice.items %} + + + + + + + + + {% endfor %} + + + + {% if order.total_discounted %} + + + + + + + {% endif %} + {% if order.total_shipping_net %} + + + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + +
{% blocktrans %}REF{% endblocktrans %}{% blocktrans %}Description{% endblocktrans %}{% blocktrans %}Quantity{% endblocktrans %}{% blocktrans %}Price per Unit{% endblocktrans %}{% blocktrans %}Tax Rate{% endblocktrans %}{% blocktrans %}Net Price{% endblocktrans %}
{{ item.reference }}{{ item.text|safe }}{{ item.quantity }}{{ item.net_price|floatformat:2 }} {{ currency }}{{ item.tax_rate }} %{{ item.total_net|floatformat:2 }} {{ currency }}
{% blocktrans %}Total discounted{% endblocktrans %}{{ order.total_discounted|floatformat:2 }} {{ currency }}
{% blocktrans %}Shipping{% endblocktrans %}{{ order.total_shipping_net|floatformat:2 }} {{ currency }}
{% blocktrans %}Net total{% endblocktrans %}{{ order.total_net|floatformat:2 }} {{ currency }}
{% blocktrans with tax=order.tax_rate %}VAT ({{ tax }}%){% endblocktrans %}{{ order.total_tax|floatformat:2 }} {{ currency }}
{% blocktrans %}Gross total{% endblocktrans %}{{ order.total_gross|floatformat:2 }} {{ currency }}
+
+ +
+

{% blocktrans %}Thank you for your order.{% endblocktrans %}

- {% if order.pay_until_date %} -

{% blocktrans with date=order.pay_until_date|date %}Please settle the payment by {{ date }}{% endblocktrans %}.

- {% endif %} + {% if order.pay_until_date %} +

{% blocktrans with date=order.pay_until_date|date %}Please settle the payment by {{ date }}{% endblocktrans %}.

+ {% endif %} - {% if order.shipping_date_range %} -

- {% blocktrans with from=order.shipping_from|date to=order.shipping_to|date %} - Order will be delivered between {{ from }} and {{ to }}. - {% endblocktrans %} -

- {% endif %} -
+ {% if order.shipping_date_range %} +

+ {% blocktrans with from=order.shipping_from|date to=order.shipping_to|date %} + Order will be delivered between {{ from }} and {{ to }}. + {% endblocktrans %} +

+ {% endif %} +
- + \ No newline at end of file diff --git a/invoice_generator/templates/style.css b/invoice_generator/templates/style.css index 9ed8c6d..fdad4dd 100644 --- a/invoice_generator/templates/style.css +++ b/invoice_generator/templates/style.css @@ -12,14 +12,14 @@ html { white-space: nowrap; } -.gross-total { +.bold { font-weight: bold; } /** * Address table */ - .adressTable tr:nth-child(1) td { + .addressTable tr:nth-child(1) td { border-bottom: 1px solid black; } diff --git a/setup.py b/setup.py index deebc97..8aa0df5 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +from unittest import TestLoader from setuptools import setup, find_packages from sys import version_info @@ -6,6 +7,12 @@ raise Exception('This project requires a Python version greater or equal than 3.5.') +def _get_test_suite(): + test_loader = TestLoader() + test_suite = test_loader.discover('tests', pattern='test_*.py') + return test_suite + + PKG_NAME = "invoice_generator" @@ -24,9 +31,13 @@ 'weasyprint', 'django', ), + extras_require={ + 'testing': ('Jinja2', ) + }, classifiers=( 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3 :: Only' ), + test_suite='setup._get_test_suite', ) diff --git a/tests/ModelsAssets.py b/tests/ModelsAssets.py new file mode 100644 index 0000000..df7f783 --- /dev/null +++ b/tests/ModelsAssets.py @@ -0,0 +1,136 @@ +import random +import datetime + +from invoice_generator import models + + +ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + + +class CachedProperty(object): + def __init__(self, fn): + self.fn = fn + self.name = fn.__name__ + + def __get__(self, instance, cls=None): + if instance is None: + return self + res = instance.__dict__[self.name] = self.fn(instance) + return res + + +class ModelsAssets: + DEFAULT_DELTATIME = datetime.timedelta(days=4) + + DEFAULT_DATE = datetime.date(2018, 2, 20) + DEFAULT_PAY_DATE = DEFAULT_DATE + DEFAULT_DELTATIME + + DEFAULT_SHIPPING_MIN = DEFAULT_PAY_DATE + DEFAULT_DELTATIME + DEFAULT_SHIPPING_MAX = DEFAULT_SHIPPING_MIN + DEFAULT_DELTATIME + + SHIPPING_RANGE = (DEFAULT_SHIPPING_MIN, DEFAULT_SHIPPING_MAX) + + DEFAULT_TAX_RATE = 20 + DEFAULT_SHIPPING_PRICE = 10.3 + + DEFAULT_TOTAL_GROSS = 12.0 + DEFAULT_TOTAL_NET = 10.0 + DEFAULT_TOTAL_TAX = 2.0 + + @property + def executive(self): + executive = models.Executive( + 'Ruth W. Blatt', 'RuthWBlatt@example.org') + + return executive + + @CachedProperty + def vendor(self): + address = models.Address( + 'Henry H. Rice', '159 Hornor Avenue', '74119', 'Tulsa, OK') + + vendor = models.Vendor( + self.executive, address, + 'VT000112', + ('Additional Information', 'test + email', 'HenryHRice@example.org')) + + return vendor + + @CachedProperty + def address(self): + address = models.Address( + 'Brittany J. Tynes', '2719 Powder House Road', + '33301', 'Fort Lauderdale, FL') + + return address + + def invoice(self, *items, **kwargs): + defaults = ( + ('order_data', self.order), + ('vendor', self.vendor), + ('billing_address', self.address), + ('items', items) + ) + + for k, fn in defaults: + if k not in kwargs: + kwargs[k] = callable(fn) and fn() or fn + + invoice = models.Invoice(**kwargs) + + return invoice + + @classmethod + def order(cls, invoice_id='INV0001', order_id='ORD0002', **data): + data.setdefault('date', cls.DEFAULT_DATE) + data.setdefault('payment_date_limit', cls.DEFAULT_PAY_DATE) + data.setdefault('shipping_date_range', cls.SHIPPING_RANGE) + data.setdefault('tax_rate', cls.DEFAULT_TAX_RATE) + data.setdefault('total_discounted', None) + data.setdefault('total_shipping_net', cls.DEFAULT_SHIPPING_PRICE) + + net = data.setdefault('total_net', cls.DEFAULT_TOTAL_NET) + gross = data.setdefault('total_gross', cls.DEFAULT_TOTAL_GROSS) + + data.setdefault('total_tax', gross - net) + + order = models.Order(invoice_id, order_id, **data) + + return order + + @CachedProperty + def item(self): + order = self.order() + + item = models.OrderItem( + order, + 'PR-ABC', 'Product Example', 1, 10.0, 10.0) + + return item + + @staticmethod + def random_item(order): + ref_length = random.randrange(2, 7) + quantity = random.randrange(1, 7) + price = float(random.randrange(50)) + total = quantity * price + + reference = 'RF-' + ''.join([ + random.choice(ALPHABET) for i in range(ref_length) + ]) + name = 'Product ' + reference + + item = models.OrderItem( + order, reference, name, quantity, price, total) + + return item + + def random_items(self, count=3, order=None): + items = [] + order = order or self.order() + + for i in range(count + 1): + item = self.random_item(order) + items.append(item) + + return items, order diff --git a/tests/TestUtils.py b/tests/TestUtils.py new file mode 100644 index 0000000..ec15e65 --- /dev/null +++ b/tests/TestUtils.py @@ -0,0 +1,30 @@ +import unittest + + +class TestUtils(unittest.TestCase): + def assertStrippedEqual(self, first: str, second: str, msg=None): + return self.assertEqual(first, second.strip(), msg) + + @staticmethod + def assertStrippedMultiIn(first: tuple, second: str): + line = '' + line_no = 0 + char_pos = 0 + + while line_no < len(first) and char_pos < len(second): + c = second[char_pos] + + if c == '\n': + if first[line_no] == line.strip(): + line_no += 1 + else: + line_no = 0 + + line = '' + else: + line += c + + char_pos += 1 + + if line_no != len(first): + raise AssertionError("{} not in {}".format(first, second)) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..50eb991 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +import os +os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..af328ae --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,23 @@ +SECRET_KEY = 'you_saw_nothing.' + +context_processors = [ + 'django.template.context_processors.i18n'] + +loaders = [ + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader'] + + +TEMPLATES = [{ + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'OPTIONS': { + 'debug': True, + 'context_processors': context_processors, + 'loaders': loaders, + 'string_if_invalid': '<< MISSING VARIABLE: %s >>'}}] + + +INSTALLED_APPS = [ + 'invoice_generator.apps.InvoiceGeneratorConfig' +] diff --git a/tests/templates/index.html b/tests/templates/index.html new file mode 100644 index 0000000..39fb1b8 --- /dev/null +++ b/tests/templates/index.html @@ -0,0 +1,10 @@ + + + + It's a test. + + + +

Test template

+ + diff --git a/tests/templates/logo-document.svg b/tests/templates/logo-document.svg new file mode 100644 index 0000000..839fcd9 --- /dev/null +++ b/tests/templates/logo-document.svg @@ -0,0 +1,15 @@ + + + + diff --git a/tests/templates/style.css b/tests/templates/style.css new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_builder.py b/tests/test_builder.py new file mode 100644 index 0000000..71dfce9 --- /dev/null +++ b/tests/test_builder.py @@ -0,0 +1,140 @@ +import os.path +from xml.etree.ElementTree import Element + +from django import setup +from jinja2 import Template + +from invoice_generator import builder +from tests.ModelsAssets import ModelsAssets +from tests.TestUtils import TestUtils + +HERE = os.path.dirname(__file__) + + +class TestBuilder(TestUtils, ModelsAssets): + def setUp(self): + setup() + + def generate_wrapper(self, currency='€', invoice=None, **kwargs): + + invoice = invoice or self.invoice() + pdf = builder.generate_pdf(currency, invoice, **kwargs) + + return pdf + + def test_custom_template(self): + tpl_path = os.path.join(HERE, 'templates', 'index.html') + + with open(tpl_path) as fp: + tpl = Template(fp.read()) + + pdf = self.generate_wrapper(template=tpl) + title = pdf.wrapper_element.query('title') + paragraph = pdf.wrapper_element.query('body p') + + self.assertIsNotNone(paragraph) + + element = paragraph.etree_element # type: Element + self.assertEqual(element.text, 'Test template') + + self.assertEqual(title.etree_element.text, 'It\'s a test.') + + def test_no_given_template(self): + wrapper = self.generate_wrapper() + title = wrapper.wrapper_element.query('head title') + + self.assertIsNotNone(title) + + element = title.etree_element # type: Element + self.assertStrippedEqual('Invoice INV0001', element.text) + + def test_localization(self): + pass + + def test_render_css(self): + wrapper = self.generate_wrapper() + doc = wrapper.wrapper_element + css = doc.query('html head style').etree_element.text + + # test if information about vendor are here + self.assertStrippedMultiIn( + ( + 'content: "Henry H. Rice" "\\a"', + '"159 Hornor Avenue" "\\a"', + '"74119 Tulsa, OK" "\\a"', + '"VAT Number: VT000112" "\\a"', + '"\\a" "\\a"', + '}' + ), css) + + # test if additional information are here + self.assertStrippedMultiIn( + ( + 'content: "Additional Information" "\\a" ' + '"test + email" "\\a" "HenryHRice@example.org" "\\a"', + + '"\\a" "\\a" "\\a"' + ), css) + + # test if information about the executive are here + self.assertStrippedMultiIn( + ( + 'content: "Management Board" "\\a"', + '"Ruth W. Blatt" "\\a"', + '"RuthWBlatt@example.org" "\\a"', + '"\\a\\a\\a"', + '}', + ), css) + + def test_render_invoice_info(self): + wrapper = self.generate_wrapper() + doc = wrapper.wrapper_element + data = doc.query_all('table.infoTable tr td') + + expected_data = ( + 'Order ID: INV0001', + 'Customer ID: ORD0002', + 'Date of Invoice: Feb. 20, 2018', + ) + + found = 0 + + for node in data: + text = node.etree_element.text + if text: + if text.strip() in expected_data: + found += 1 + + self.assertEqual(len(expected_data), found) + + def test_render_order_info(self): + cells = ( + ('reference', str), + ('text', str), + ('quantity', str), + ('net_price', lambda x: '%.2f €' % x), + ('tax_rate', lambda x: '%d %%' % x), + ('total_net', lambda x: '%.2f €' % x), + ) + + items, order = self.random_items() + invoice = self.invoice(*items, order_data=order) + wrapper = self.generate_wrapper(invoice=invoice) + + doc = wrapper.wrapper_element + rows = doc.query_all('table.invoiceTable tbody tr:not(.tfoot)') + + for i, row in enumerate(rows): + self.assertLess(i, len(items), 'Got more items than expected.') + + item = items[i] + for cell_pos, cell in enumerate(row.query_all('td')): + cell = cell.etree_element.text + self.assertLess( + cell_pos, len(cells), + 'Got an unexpected cell in product %d.' % i + ) + + attr, transformer = cells[cell_pos] + self.assertEqual( + transformer(getattr(item, attr)), cell.strip()) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..8320989 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,15 @@ +import unittest +from invoice_generator import models +from tests.ModelsAssets import ModelsAssets + + +class TestModels(unittest.TestCase, ModelsAssets): + def test_Vendor_additional_text_suffix(self): + vendor = self.vendor # type: models.Vendor + self.assertEqual(3 * '"\\a" ', vendor.additional_text_suffix) + + def test_Order_shipping_from(self): + order = self.order() # type: models.Order + + self.assertEqual(order.shipping_from, self.DEFAULT_SHIPPING_MIN) + self.assertEqual(order.shipping_to, self.DEFAULT_SHIPPING_MAX)