├── tests ├── __init__.py ├── _site │ ├── __init__.py │ ├── apps │ │ ├── __init__.py │ │ └── custom_invoices │ │ │ ├── __init__.py │ │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0002_auto_20200917_1556.py │ │ │ └── 0001_initial.py │ │ │ ├── app.py │ │ │ └── models.py │ └── urls.py ├── factories.py ├── settings.py └── test_invoices.py ├── docs ├── requirements.txt ├── source │ ├── storages.rst │ ├── quickstart.rst │ ├── settings.rst │ ├── index.rst │ └── conf.py └── Makefile ├── oscar_invoices ├── migrations │ ├── __init__.py │ ├── 0003_auto_20190403_1910.py │ ├── 0002_auto_20180822_1335.py │ ├── 0005_legalentity_company_number_and_more.py │ ├── 0004_date_banking_details.py │ └── 0001_initial.py ├── __init__.py ├── locale │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── nl │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── app_settings.py ├── admin.py ├── apps.py ├── models.py ├── views.py ├── storages.py ├── utils.py ├── abstract_models.py └── templates │ └── oscar_invoices │ └── invoice.html ├── sandbox ├── test_app │ ├── migrations │ │ └── __init__.py │ ├── __init__.py │ ├── apps.py │ └── receivers.py ├── wsgi.py ├── urls.py ├── manage.py └── settings.py ├── .readthedocs.yml ├── MANIFEST.in ├── .gitignore ├── requirements.txt ├── tox.ini ├── setup.cfg ├── setup.py ├── README.rst └── .github └── workflows └── test.yml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_site/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -e . -------------------------------------------------------------------------------- /tests/_site/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oscar_invoices/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sandbox/test_app/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_site/apps/custom_invoices/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_site/apps/custom_invoices/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sandbox/test_app/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'test_app.apps.TestAppConfig' 2 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | build: 2 | image: latest 3 | 4 | python: 5 | version: 3.6 6 | -------------------------------------------------------------------------------- /oscar_invoices/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'oscar_invoices.apps.InvoicesConfig' 2 | -------------------------------------------------------------------------------- /oscar_invoices/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-invoices/HEAD/oscar_invoices/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oscar_invoices/locale/nl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-invoices/HEAD/oscar_invoices/locale/nl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | recursive-include oscar_invoices/templates *.html 3 | recursive-include oscar_invoices *.po 4 | recursive-include oscar_invoices *.mo 5 | -------------------------------------------------------------------------------- /docs/source/storages.rst: -------------------------------------------------------------------------------- 1 | Storages 2 | ======== 3 | 4 | .. automodule:: oscar_invoices.storages 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /sandbox/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /sandbox/test_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | name = 'test_app' 6 | 7 | def ready(self): 8 | from . import receivers # noqa isort:skip 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | db.sqlite3 4 | 5 | media/ 6 | documents/ # Folder with invoices 7 | 8 | # ide 9 | .idea/ 10 | 11 | # testing 12 | .pytest_cache/ 13 | django_oscar_invoices.egg-info/ 14 | dist/ 15 | .tox/* 16 | -------------------------------------------------------------------------------- /tests/_site/apps/custom_invoices/app.py: -------------------------------------------------------------------------------- 1 | from oscar.core.application import Application 2 | 3 | 4 | class CustomInvoicesApplication(Application): 5 | name = 'custom_invoices' 6 | 7 | 8 | application = CustomInvoicesApplication() 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Testing 2 | pytest>=7.4.4 3 | pytest-django 4 | django-webtest>=1.9.11 5 | sorl-thumbnail 6 | psycopg2-binary>=2.9.9 7 | sorl-thumbnail 8 | coverage 9 | mock>=5.1.0 10 | factory-boy==3.2.1 11 | 12 | # Development 13 | flake8 14 | isort 15 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{39}-django{42} 4 | 5 | [testenv] 6 | commands = pytest {posargs} 7 | setenv = 8 | PYTHONPATH=. 9 | deps = 10 | django42: django>=4.2.6 11 | pytest >= 7.4.4 12 | pytest-django >= 4.7.0 13 | django-webtest>=1.9.11 14 | sorl-thumbnail 15 | psycopg2-binary>=2.9.9 16 | mock 17 | -------------------------------------------------------------------------------- /oscar_invoices/app_settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | OSCAR_INVOICES_DOCUMENTS_ROOT = getattr(settings, 'OSCAR_INVOICES_DOCUMENTS_ROOT', 'documents/') 4 | 5 | OSCAR_INVOICES_UPLOAD_FOLDER = getattr(settings, 'OSCAR_INVOICES_UPLOAD_FOLDER', 'invoices/%Y/%m/') 6 | 7 | OSCAR_INVOICES_INVOICE_MODEL = getattr(settings, 'OSCAR_INVOICES_INVOICE_MODEL', 'oscar_invoices.Invoice') 8 | -------------------------------------------------------------------------------- /sandbox/test_app/receivers.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from oscar.apps.order.signals import order_placed 3 | from oscar.core.loading import get_class 4 | 5 | InvoiceCreator = get_class('oscar_invoices.utils', 'InvoiceCreator') 6 | 7 | 8 | @receiver(order_placed) 9 | def receive_order_placed(sender, order, **kwargs): 10 | InvoiceCreator().create_invoice(order) 11 | -------------------------------------------------------------------------------- /tests/_site/urls.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf.urls import i18n 3 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 4 | from django.urls import include, path 5 | 6 | urlpatterns = [ 7 | path('i18n/', include(i18n)), 8 | path('', include(apps.get_app_config('oscar').urls[0])), 9 | ] 10 | 11 | urlpatterns += staticfiles_urlpatterns() 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests/ 3 | DJANGO_SETTINGS_MODULE = tests.settings 4 | 5 | [flake8] 6 | exclude=*migrations* 7 | ignore = F405,W503,E731 8 | max-complexity = 10 9 | max-line-length=119 10 | 11 | [isort] 12 | line_length = 119 13 | multi_line_output = 4 14 | balanced_wrapping = true 15 | use_parentheses = true 16 | known_first_party = oscar_invoices 17 | known_third_party= oscar,pytest 18 | skip_glob=*/migrations/*,*/settings.py 19 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | Installation 5 | ------------ 6 | 7 | .. code-block:: console 8 | 9 | $ pip install django-oscar-invoices 10 | 11 | 12 | Setup 13 | ===== 14 | 15 | 1. Add ``oscar_invoices`` to the ``INSTALLED_APPS`` variable of your 16 | project's ``settings.py``. 17 | 18 | 2. Sync the database using ``python manage.py migrate``. 19 | 20 | 3. Create instances of ``LegalEntity`` and ``LegalEntityAddress``. 21 | 22 | 4. Integrate ``InvoiceCreator`` in your checkout process. 23 | -------------------------------------------------------------------------------- /oscar_invoices/migrations/0003_auto_20190403_1910.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.8 on 2019-04-03 19:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('oscar_invoices', '0002_auto_20180822_1335'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='invoice', 15 | name='number', 16 | field=models.CharField(max_length=128, unique=True, verbose_name='Invoice number'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /sandbox/urls.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | from django.conf.urls import i18n 4 | from django.conf.urls.static import static 5 | from django.contrib import admin 6 | from django.urls import include, path 7 | 8 | urlpatterns = [ 9 | path('admin/', admin.site.urls), 10 | path('i18n/', include(i18n)), 11 | path('', include(apps.get_app_config("oscar").urls[0])), 12 | path('', apps.get_app_config("oscar_invoices").urls), 13 | ] 14 | 15 | if settings.DEBUG: 16 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 17 | -------------------------------------------------------------------------------- /sandbox/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /oscar_invoices/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from oscar.core.loading import get_model 3 | 4 | from .models import is_custom_invoice_model 5 | 6 | LegalEntity = get_model('oscar_invoices', 'LegalEntity') 7 | LegalEntityAddress = get_model('oscar_invoices', 'LegalEntityAddress') 8 | 9 | admin.site.register(LegalEntity) 10 | admin.site.register(LegalEntityAddress) 11 | 12 | 13 | class InvoiceAdmin(admin.ModelAdmin): 14 | exclude = ("document",) 15 | 16 | 17 | if not is_custom_invoice_model: 18 | Invoice = get_model('oscar_invoices', 'Invoice') 19 | admin.site.register(Invoice, InvoiceAdmin) 20 | -------------------------------------------------------------------------------- /oscar_invoices/migrations/0002_auto_20180822_1335.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.8 on 2018-08-22 13:35 2 | 3 | from django.db import migrations, models 4 | import oscar_invoices.storages 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('oscar_invoices', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='invoice', 16 | name='document', 17 | field=models.FileField(blank=True, max_length=255, null=True, storage=oscar_invoices.storages.DocumentsStorage(), upload_to='invoices/%Y/%m/', verbose_name='Document'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = django-oscar-invoices 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /oscar_invoices/apps.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from django.utils.translation import gettext_lazy as _ 3 | from oscar.core.application import OscarConfig 4 | 5 | 6 | class InvoicesConfig(OscarConfig): 7 | label = 'oscar_invoices' 8 | name = 'oscar_invoices' 9 | verbose_name = _('Invoices') 10 | 11 | default_permissions = ["is_staff"] 12 | 13 | default = True 14 | 15 | def ready(self): 16 | from . import views 17 | self.invoice_view = views.InvoicePreviewView 18 | 19 | def get_urls(self): 20 | urlpatterns = [ 21 | re_path(r"invoice/(?P\d+)/", self.invoice_view.as_view(), 22 | name="invoice"), 23 | ] 24 | return self.post_process_urls(urlpatterns) 25 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from oscar.core.loading import get_model 3 | 4 | __all__ = ['LegalEntityAddressFactory', 'LegalEntityFactory'] 5 | 6 | 7 | class LegalEntityFactory(factory.django.DjangoModelFactory): 8 | business_name = 'Test Company' 9 | company_number = 'test-company-number' 10 | 11 | class Meta: 12 | model = get_model('oscar_invoices', 'LegalEntity') 13 | 14 | 15 | class LegalEntityAddressFactory(factory.django.DjangoModelFactory): 16 | legal_entity = factory.SubFactory(LegalEntityFactory) 17 | line1 = '1 Egg Street' 18 | line2 = 'London' 19 | postcode = 'N12 9RE' 20 | country = factory.SubFactory('oscar.test.factories.CountryFactory') 21 | 22 | class Meta: 23 | model = get_model('oscar_invoices', 'LegalEntityAddress') 24 | -------------------------------------------------------------------------------- /oscar_invoices/migrations/0005_legalentity_company_number_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-03-23 19:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('oscar_invoices', '0004_date_banking_details'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='legalentity', 15 | name='company_number', 16 | field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Company identification number'), 17 | ), 18 | migrations.AlterField( 19 | model_name='legalentity', 20 | name='vat_number', 21 | field=models.CharField(blank=True, max_length=20, null=True, verbose_name='VAT identification number'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tests/_site/apps/custom_invoices/migrations/0002_auto_20200917_1556.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.16 on 2020-09-17 15:56 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('custom_invoices', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='custominvoice', 16 | name='date', 17 | field=models.DateField(auto_now_add=True, default=django.utils.timezone.now), 18 | preserve_default=False, 19 | ), 20 | migrations.AlterField( 21 | model_name='custominvoice', 22 | name='number', 23 | field=models.CharField(max_length=128, unique=True, verbose_name='Invoice number'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /oscar_invoices/models.py: -------------------------------------------------------------------------------- 1 | from oscar.core.loading import is_model_registered 2 | 3 | from oscar_invoices import app_settings 4 | from oscar_invoices.abstract_models import AbstractInvoice, AbstractLegalEntity, AbstractLegalEntityAddress 5 | 6 | __all__ = [] 7 | 8 | is_custom_invoice_model = app_settings.OSCAR_INVOICES_INVOICE_MODEL != 'oscar_invoices.Invoice' 9 | 10 | 11 | if not is_model_registered('oscar_invoices', 'Invoice') and not is_custom_invoice_model: 12 | class Invoice(AbstractInvoice): 13 | pass 14 | 15 | __all__.append('Invoice') 16 | 17 | 18 | if not is_model_registered('oscar_invoices', 'LegalEntity'): 19 | class LegalEntity(AbstractLegalEntity): 20 | pass 21 | 22 | __all__.append('LegalEntity') 23 | 24 | 25 | if not is_model_registered('oscar_invoices', 'LegalEntityAddress'): 26 | class LegalEntityAddress(AbstractLegalEntityAddress): 27 | pass 28 | 29 | __all__.append('LegalEntityAddress') 30 | -------------------------------------------------------------------------------- /tests/_site/apps/custom_invoices/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from oscar_invoices.abstract_models import AbstractInvoice 5 | 6 | 7 | class CustomInvoice(AbstractInvoice): 8 | """ 9 | An Custom invoice for tests. 10 | """ 11 | 12 | legal_entity = models.ForeignKey( 13 | 'oscar_invoices.LegalEntity', 14 | on_delete=models.CASCADE, 15 | related_name='custom_invoices', 16 | verbose_name=_('Legal Entity')) 17 | 18 | order = models.OneToOneField( 19 | 'order.Order', verbose_name=_('Order'), related_name='custom_invoice', 20 | null=True, blank=True, on_delete=models.SET_NULL) 21 | 22 | additional_test_field = models.CharField( 23 | help_text='This field added just for test purposes', 24 | max_length=120) 25 | 26 | class Meta: 27 | app_label = 'custom_invoices' 28 | verbose_name = _('Custom invoice') 29 | verbose_name_plural = _('Custom invoices') 30 | -------------------------------------------------------------------------------- /oscar_invoices/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import UserPassesTestMixin 2 | from django.http import HttpResponse 3 | from django.views.generic import View 4 | from django.views.generic.detail import SingleObjectMixin 5 | from oscar.core.loading import get_class, get_model 6 | 7 | Invoice = get_model('oscar_invoices', 'Invoice') 8 | InvoiceCreator = get_class('oscar_invoices.utils', 'InvoiceCreator') 9 | 10 | 11 | class InvoicePreviewView(UserPassesTestMixin, SingleObjectMixin, View): 12 | queryset = Invoice.objects.all() 13 | 14 | def test_func(self): 15 | user = self.request.user 16 | return user.is_staff 17 | 18 | def get(self, request, *args, **kwargs): 19 | invoice = self.get_object() 20 | rendered_invoice = InvoiceCreator().render_document( 21 | invoice=invoice, 22 | legal_entity=invoice.legal_entity, 23 | order=invoice.order, 24 | use_path=False, 25 | request=request 26 | ) 27 | return HttpResponse(rendered_invoice) 28 | -------------------------------------------------------------------------------- /docs/source/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | 5 | ``OSCAR_INVOICES_DOCUMENT_ROOT`` 6 | -------------------------------- 7 | 8 | Default: ``documents/`` 9 | 10 | Location of the document directory, which contains sensitive data and hence 11 | should not be publicly available (as media files). 12 | 13 | ``OSCAR_INVOICES_FOLDER`` 14 | ------------------------ 15 | 16 | Default: ``invoices/%Y/%m/`` 17 | 18 | The location within the ``OSCAR_INVOICES_DOCUMENT_ROOT`` folder that is used to store generated invoices. 19 | The folder name can contain date format strings as described in the `Django Docs`_. 20 | 21 | .. _`Django Docs`: https://docs.djangoproject.com/en/stable/ref/models/fields/#filefield 22 | 23 | 24 | ``OSCAR_INVOICES_INVOICE_MODEL`` 25 | -------------------------------- 26 | 27 | Default: ``oscar_invoices.Invoice`` 28 | 29 | Path to the invoice model. Since Oscar does not allow to fork 30 | external applications, in order to customize invoice model you 31 | will need to create it in your project and specify path to it 32 | in the setting. 33 | -------------------------------------------------------------------------------- /oscar_invoices/migrations/0004_date_banking_details.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-06-05 10:32 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('oscar_invoices', '0003_auto_20190403_1910'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='invoice', 16 | name='date', 17 | field=models.DateField(auto_now_add=True, default=django.utils.timezone.now), 18 | preserve_default=False, 19 | ), 20 | migrations.AddField( 21 | model_name='legalentity', 22 | name='bic', 23 | field=models.CharField(blank=True, max_length=255, null=True, verbose_name='BIC'), 24 | ), 25 | migrations.AddField( 26 | model_name='legalentity', 27 | name='iban', 28 | field=models.CharField(blank=True, max_length=255, null=True, verbose_name='IBAN'), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | 4 | setup( 5 | name='django-oscar-invoices', 6 | version='0.2.2', 7 | url='https://github.com/django-oscar/django-oscar-invoices', 8 | author='Metaclass Team', 9 | author_email='sasha@metaclass.co', 10 | description='Invoices generation for Django Oscar', 11 | long_description=open('README.rst').read(), 12 | license='BSD', 13 | packages=find_packages(exclude=['sandbox*', 'tests*']), 14 | include_package_data=True, 15 | zip_safe=False, 16 | classifiers=[ 17 | 'Development Status :: 4 - Beta', 18 | 'Environment :: Web Environment', 19 | 'Framework :: Django', 20 | 'Framework :: Django :: 4.0', 21 | 'Intended Audience :: Developers', 22 | 'License :: OSI Approved :: BSD License', 23 | 'Operating System :: Unix', 24 | 'Programming Language :: Python :: 3', 25 | 'Programming Language :: Python :: 3.9.18', 26 | ], 27 | install_requires=[ 28 | 'phonenumbers', 29 | 'pillow', 30 | 'django>=3.2', 31 | 'django-oscar>=3.2.2', 32 | 'django-phonenumber-field>=6.4.0', 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /oscar_invoices/storages.py: -------------------------------------------------------------------------------- 1 | from django.core.files.storage import FileSystemStorage 2 | from django.utils.deconstruct import deconstructible 3 | from django.utils.functional import cached_property 4 | 5 | from . import app_settings 6 | 7 | 8 | @deconstructible 9 | class DocumentsStorage(FileSystemStorage): 10 | """ 11 | Custom filesystem storage for storing documents outside of media directory 12 | (destination folder - `settings.OSCAR_INVOICES_DOCUMENT_ROOT`) and 13 | restricting their public access via URL. 14 | """ 15 | 16 | def __init__(self, *args, **kwargs): 17 | super(DocumentsStorage, self).__init__(*args, **kwargs) 18 | self._location = app_settings.OSCAR_INVOICES_DOCUMENTS_ROOT 19 | 20 | def _clear_cached_properties(self, setting, **kwargs): 21 | if setting == 'OSCAR_INVOICES_DOCUMENTS_ROOT': 22 | self.__dict__.pop('base_location', None) 23 | self.__dict__.pop('location', None) 24 | 25 | elif setting == 'FILE_UPLOAD_PERMISSIONS': 26 | self.__dict__.pop('file_permissions_mode', None) 27 | 28 | elif setting == 'FILE_UPLOAD_DIRECTORY_PERMISSIONS': 29 | self.__dict__.pop('directory_permissions_mode', None) 30 | 31 | @cached_property 32 | def base_location(self): 33 | return self._value_or_setting(self._location, app_settings.OSCAR_INVOICES_DOCUMENTS_ROOT) 34 | 35 | def url(self, name): 36 | raise ValueError("This file is not accessible via a URL.") 37 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | django-oscar-invoices 3 | ===================== 4 | 5 | In order to generate invoice it's required to create two model records: 6 | 7 | * Merchant account, ``oscar_invoices.models.LegalEntity``. 8 | In contains shop name, seller's business name, website, email, VAT number etc. 9 | 10 | * Merchant address, ``oscar_invoices.models.LegalEntityAddress``. It's 11 | quite similar to the order shipping or billing address. 12 | 13 | By default, we generate only HTML invoice document and allow user to decide how to 14 | generate PDF documents. You can integrate `python-pdfkit`_, `WeasyPrint`_, `xhtml2pdf`_, 15 | `reportlab`_ or another library of your choice. 16 | 17 | .. _`python-pdfkit`: https://github.com/JazzCore/python-pdfkit 18 | .. _`WeasyPrint`: https://github.com/Kozea/WeasyPrint 19 | .. _`xhtml2pdf`: https://github.com/xhtml2pdf/xhtml2pdf 20 | .. _`reportlab`: https://www.reportlab.com/ 21 | 22 | Since documents contains sensitive data, we store them out of the media folder 23 | and do not provide public access via URL. For this purpose, we use custom 24 | storage class ``oscar_invoices.storages.DocumentsStorage``, invoice documents 25 | placed into the nested folder ``settings.OSCAR_INVOICES_UPLOAD_FOLDER`` and 26 | available for the admin users via dashboard order list. 27 | 28 | In order to start generating invoices you need to integrate 29 | ``oscar_invoices.utils.InvoiceCreator`` into your checkout flow, for instance 30 | to `order_placed` signal receiver. 31 | 32 | .. toctree:: 33 | :maxdepth: 1 34 | 35 | quickstart 36 | settings 37 | storages 38 | 39 | 40 | Indices and tables 41 | ================== 42 | 43 | * :ref:`genindex` 44 | * :ref:`modindex` 45 | * :ref:`search` 46 | -------------------------------------------------------------------------------- /tests/_site/apps/custom_invoices/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1 on 2018-08-25 16:12 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import oscar_invoices.storages 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('order', '0005_update_email_length'), 14 | ('oscar_invoices', '0002_auto_20180822_1335'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='CustomInvoice', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('number', models.CharField(max_length=128, verbose_name='Invoice number')), 23 | ('notes', models.TextField(null=True, verbose_name='Notes for invoice')), 24 | ('document', models.FileField(blank=True, max_length=255, null=True, storage=oscar_invoices.storages.DocumentsStorage(), upload_to='invoices/%Y/%m/', verbose_name='Document')), 25 | ('additional_test_field', models.CharField(help_text='This field added just for test purposes', max_length=120)), 26 | ('legal_entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='custom_invoices', to='oscar_invoices.LegalEntity', verbose_name='Legal Entity')), 27 | ('order', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='custom_invoice', to='order.Order', verbose_name='Order')), 28 | ], 29 | options={ 30 | 'verbose_name': 'Custom invoice', 31 | 'verbose_name_plural': 'Custom invoices', 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | django-oscar-invoices 3 | ===================== 4 | 5 | Quickstart 6 | ========== 7 | 8 | Installation 9 | ------------ 10 | 11 | .. code-block:: console 12 | 13 | $ pip install django-oscar-invoices 14 | 15 | 16 | Setup 17 | ----- 18 | 19 | 1. Add ``oscar_invoices`` to the ``INSTALLED_APPS`` variable of your 20 | project's ``settings.py``. 21 | 22 | 2. Sync the database using ``python manage.py migrate``. 23 | 24 | 3. Create instances of ``LegalEntity`` and ``LegalEntityAddress``. 25 | 26 | 4. Integrate ``InvoiceCreator`` in your checkout process. 27 | 28 | 29 | By default, we generate only HTML invoice document and allow user to decide how to 30 | generate PDF documents. You can integrate `python-pdfkit`_, `WeasyPrint`_, `xhtml2pdf`_, 31 | `reportlab`_ or another library of your choice. 32 | 33 | .. _`python-pdfkit`: https://github.com/JazzCore/python-pdfkit 34 | .. _`WeasyPrint`: https://github.com/Kozea/WeasyPrint 35 | .. _`xhtml2pdf`: https://github.com/xhtml2pdf/xhtml2pdf 36 | .. _`reportlab`: https://www.reportlab.com/ 37 | 38 | Since documents contains sensitive data, we store them out of the media folder and 39 | do not provide public access via URL. For this purpose, we use custom storage class 40 | ``oscar_invoices.storages.DocumentsStorage``, invoice documents placed into the 41 | nested folder ``settings.OSCAR_INVOICES_UPLOAD_FOLDER`` and available for the admin users via 42 | dashboard order list. 43 | 44 | 45 | You can find more information in documentation_. 46 | 47 | .. _documentation: https://django-oscar-invoices.readthedocs.io 48 | 49 | 50 | Sandbox 51 | ------- 52 | 53 | Sandbox environment set up to automatically create invoices on checkout. 54 | But for this, instances of ``LegalEntity`` and ``LegalEntityAddress`` must be created 55 | (from ``admin`` site) before order placement. -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: true 16 | matrix: 17 | python-version: ['3.9', '3.10', '3.11'] 18 | django-version: ['3.2', '4.0', '4.2'] 19 | services: 20 | postgres: 21 | image: postgres:14 22 | ports: 23 | - 5432:5432 24 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 25 | env: 26 | POSTGRES_USER: postgres 27 | POSTGRES_PASSWORD: postgres 28 | POSTGRES_DB: postgres 29 | steps: 30 | - uses: actions/checkout@v4 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v2 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install -e .[test] 39 | pip install -r requirements.txt 40 | pip install "django~=${{ matrix.django-version }}.0" 41 | - name: Run tests 42 | env: 43 | DATABASE_USER: postgres 44 | DATABASE_PASSWORD: postgres 45 | DATABASE_HOST: localhost 46 | DATABASE_NAME: postgres 47 | run: | 48 | coverage run --parallel -m pytest -x 49 | lint_python: 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | - name: Set up Python ${{ matrix.python-version }} 54 | uses: actions/setup-python@v5 55 | with: 56 | python-version: '3.11' 57 | - name: Install dependencies 58 | run: | 59 | python -m pip install --upgrade pip 60 | pip install -e .[test] 61 | pip install -r requirements.txt 62 | - name: Run linters 63 | run: | 64 | flake8 oscar_invoices tests sandbox setup.py 65 | isort -c -q --diff oscar_invoices/ sandbox/ tests/ 66 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from oscar.defaults import * # noqa 4 | 5 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 6 | 7 | SECRET_KEY = 'TEST_SECRET_KEY' 8 | 9 | DEBUG = True 10 | 11 | ALLOWED_HOSTS = [] 12 | 13 | INSTALLED_APPS = [ 14 | 'django.contrib.auth', 15 | 'django.contrib.contenttypes', 16 | 'django.contrib.sessions', 17 | 'django.contrib.messages', 18 | 'django.contrib.staticfiles', 19 | 20 | 'django.contrib.sites', 21 | 'django.contrib.flatpages', 22 | 23 | 'oscar.config.Shop', 24 | 'oscar.apps.analytics.apps.AnalyticsConfig', 25 | 'oscar.apps.checkout.apps.CheckoutConfig', 26 | 'oscar.apps.address.apps.AddressConfig', 27 | 'oscar.apps.shipping.apps.ShippingConfig', 28 | 'oscar.apps.catalogue.apps.CatalogueConfig', 29 | 'oscar.apps.catalogue.reviews.apps.CatalogueReviewsConfig', 30 | 'oscar.apps.communication.apps.CommunicationConfig', 31 | 'oscar.apps.partner.apps.PartnerConfig', 32 | 'oscar.apps.basket.apps.BasketConfig', 33 | 'oscar.apps.payment.apps.PaymentConfig', 34 | 'oscar.apps.offer.apps.OfferConfig', 35 | 'oscar.apps.order.apps.OrderConfig', 36 | 'oscar.apps.customer.apps.CustomerConfig', 37 | 'oscar.apps.search.apps.SearchConfig', 38 | 'oscar.apps.voucher.apps.VoucherConfig', 39 | 'oscar.apps.wishlists.apps.WishlistsConfig', 40 | 'oscar.apps.dashboard.apps.DashboardConfig', 41 | 'oscar.apps.dashboard.reports.apps.ReportsDashboardConfig', 42 | 'oscar.apps.dashboard.users.apps.UsersDashboardConfig', 43 | 'oscar.apps.dashboard.orders.apps.OrdersDashboardConfig', 44 | 'oscar.apps.dashboard.catalogue.apps.CatalogueDashboardConfig', 45 | 'oscar.apps.dashboard.offers.apps.OffersDashboardConfig', 46 | 'oscar.apps.dashboard.partners.apps.PartnersDashboardConfig', 47 | 'oscar.apps.dashboard.pages.apps.PagesDashboardConfig', 48 | 'oscar.apps.dashboard.ranges.apps.RangesDashboardConfig', 49 | 'oscar.apps.dashboard.reviews.apps.ReviewsDashboardConfig', 50 | 'oscar.apps.dashboard.vouchers.apps.VouchersDashboardConfig', 51 | 'oscar.apps.dashboard.communications.apps.CommunicationsDashboardConfig', 52 | 'oscar.apps.dashboard.shipping.apps.ShippingDashboardConfig', 53 | 54 | 'oscar_invoices.apps.InvoicesConfig', 55 | 'tests._site.apps.custom_invoices', 56 | ] 57 | 58 | ROOT_URLCONF = 'tests._site.urls' 59 | 60 | TEMPLATES = [ 61 | { 62 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 63 | 'DIRS': [ 64 | os.path.join(BASE_DIR, 'templates'), 65 | ], 66 | 'APP_DIRS': True, 67 | }, 68 | ] 69 | 70 | DATABASES = { 71 | 'default': { 72 | 'ENGINE': os.environ.get('DATABASE_ENGINE', 'django.db.backends.postgresql'), 73 | 'HOST': os.environ.get('DATABASE_HOST', 'localhost'), 74 | 'NAME': os.environ.get('DATABASE_NAME', 'oscar_invoices_test'), 75 | 'USER': os.environ.get('DATABASE_USER', 'postgres'), 76 | 'PASSWORD': os.environ.get('DATABASE_PASSWORD', None), 77 | 'PORT': os.environ.get('DATABASE_PORT', 5432) 78 | } 79 | } 80 | 81 | LANGUAGE_CODE = 'en-us' 82 | 83 | TIME_ZONE = 'UTC' 84 | 85 | STATIC_URL = '/static/' 86 | 87 | MEDIA_URL = '/media/' 88 | 89 | MEDIA_ROOT = os.path.join(BASE_DIR, 'sandbox', 'media') 90 | 91 | HAYSTACK_CONNECTIONS = { 92 | 'default': { 93 | 'ENGINE': 'haystack.backends.simple_backend.SimpleEngine', 94 | }, 95 | } 96 | 97 | SITE_ID = 1 98 | -------------------------------------------------------------------------------- /oscar_invoices/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | from django.core.files.base import ContentFile 5 | from django.template.loader import render_to_string 6 | from oscar.core.loading import get_model 7 | 8 | from . import app_settings 9 | 10 | logger = logging.getLogger('oscar_invoices') 11 | 12 | LegalEntity = get_model('oscar_invoices', 'LegalEntity') 13 | 14 | 15 | class InvoiceCreator(object): 16 | _invoice_model = None 17 | 18 | def get_legal_entity(self): 19 | return LegalEntity.objects.first() 20 | 21 | def get_invoice_model(self): 22 | if not self._invoice_model: 23 | app_label, model_name = app_settings.OSCAR_INVOICES_INVOICE_MODEL.split('.') 24 | self._invoice_model = get_model(app_label, model_name) 25 | return self._invoice_model 26 | 27 | def get_invoice_filename(self, invoice): 28 | return 'invoice_{}.html'.format(invoice.number) 29 | 30 | def generate_invoice_number(self, order, **kwargs): 31 | year_last_two_numbers = datetime.now().year % 100 32 | return '{}{:06d}'.format(year_last_two_numbers, order.id) # E.g. "19000001' 33 | 34 | def get_invoice_template_context(self, invoice, **kwargs): 35 | order = kwargs.pop('order', None) 36 | legal_entity = kwargs.pop('legal_entity', None) 37 | template_context = { 38 | 'invoice': invoice, 39 | 'order': order, 40 | 'legal_entity': legal_entity, 41 | 'legal_entity_address': legal_entity.addresses.first(), 42 | } 43 | template_context.update(**kwargs) 44 | return template_context 45 | 46 | def render_document(self, invoice, **kwargs): 47 | """ 48 | Return rendered from html template invoice document. 49 | """ 50 | template_name = 'oscar_invoices/invoice.html' 51 | template_context = self.get_invoice_template_context(invoice, **kwargs) 52 | return render_to_string(template_name, template_context) 53 | 54 | def generate_document(self, invoice, **kwargs): 55 | """ 56 | Create and save invoice document (as *.html file). 57 | """ 58 | return ContentFile(self.render_document(invoice, **kwargs)) 59 | 60 | def create_invoice_model(self, **kwargs): 61 | Invoice = self.get_invoice_model() 62 | invoice = Invoice.objects.create(**kwargs) 63 | order = kwargs['order'] 64 | document_file = self.generate_document(invoice, **kwargs) 65 | invoice.document.save(self.get_invoice_filename(invoice), document_file) 66 | logger.info('Created invoice %s for order #%s', kwargs['number'], order.number) 67 | return invoice 68 | 69 | def create_invoice(self, order, **extra_kwargs): 70 | """ 71 | To create `Invoice` instance, we should have at least one 72 | instance of `LegalEntity` with `LegalEntityAddress`. 73 | Some platforms may have couple `LegalEntity`s (with couple 74 | `LegalEntityAddress`s). In this case needed instances should be 75 | selected based on order (ordered products). 76 | """ 77 | 78 | legal_entity = self.get_legal_entity() 79 | if legal_entity and legal_entity.has_addresses: 80 | number = extra_kwargs.pop('number', None) 81 | if not number: 82 | number = self.generate_invoice_number(order, **extra_kwargs) 83 | return self.create_invoice_model( 84 | legal_entity=legal_entity, number=number, order=order, **extra_kwargs 85 | ) 86 | else: 87 | logger.warning( 88 | "Invoice was not generated due to missing legal entity. Please create it within the legal address." 89 | ) 90 | -------------------------------------------------------------------------------- /oscar_invoices/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-12-13 14:38+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: abstract_models.py:18 22 | msgid "Shop name" 23 | msgstr "" 24 | 25 | #: abstract_models.py:19 26 | msgid "Business name" 27 | msgstr "" 28 | 29 | #: abstract_models.py:20 30 | msgid "VAT identification number" 31 | msgstr "" 32 | 33 | #: abstract_models.py:22 34 | msgid "Logo" 35 | msgstr "" 36 | 37 | #: abstract_models.py:23 templates/oscar_invoices/invoice.html:146 38 | msgid "Email" 39 | msgstr "" 40 | 41 | #: abstract_models.py:24 42 | msgid "Website" 43 | msgstr "" 44 | 45 | #: abstract_models.py:29 abstract_models.py:50 abstract_models.py:71 46 | msgid "Legal Entity" 47 | msgstr "" 48 | 49 | #: abstract_models.py:30 50 | msgid "Legal Entities" 51 | msgstr "" 52 | 53 | #: abstract_models.py:52 54 | msgid "Phone number" 55 | msgstr "" 56 | 57 | #: abstract_models.py:53 58 | msgid "Fax number" 59 | msgstr "" 60 | 61 | #: abstract_models.py:58 62 | msgid "Legal Entity Address" 63 | msgstr "" 64 | 65 | #: abstract_models.py:59 66 | msgid "Legal Entity Addresses" 67 | msgstr "" 68 | 69 | #: abstract_models.py:74 70 | msgid "Invoice number" 71 | msgstr "" 72 | 73 | #: abstract_models.py:77 templates/oscar_invoices/invoice.html:116 74 | msgid "Order" 75 | msgstr "" 76 | 77 | #: abstract_models.py:80 78 | msgid "Notes for invoice" 79 | msgstr "" 80 | 81 | #: abstract_models.py:83 82 | msgid "Document" 83 | msgstr "" 84 | 85 | #: abstract_models.py:89 templates/oscar_invoices/invoice.html:9 86 | msgid "Invoice" 87 | msgstr "" 88 | 89 | #: abstract_models.py:90 config.py:8 90 | msgid "Invoices" 91 | msgstr "" 92 | 93 | #: abstract_models.py:101 94 | #, python-format 95 | msgid "Invoice #%(invoice_number)s for order #%(order_number)s" 96 | msgstr "" 97 | 98 | #: abstract_models.py:105 99 | #, python-format 100 | msgid "Invoice %(invoice_number)s" 101 | msgstr "" 102 | 103 | #: templates/oscar_invoices/invoice.html:115 104 | msgid "Original invoice" 105 | msgstr "" 106 | 107 | #: templates/oscar_invoices/invoice.html:117 108 | msgid "Date" 109 | msgstr "" 110 | 111 | #: templates/oscar_invoices/invoice.html:136 112 | msgid "VAT ID" 113 | msgstr "" 114 | 115 | #: templates/oscar_invoices/invoice.html:140 116 | msgid "Tel" 117 | msgstr "" 118 | 119 | #: templates/oscar_invoices/invoice.html:143 120 | msgid "Fax" 121 | msgstr "" 122 | 123 | #: templates/oscar_invoices/invoice.html:149 124 | msgid "Site" 125 | msgstr "" 126 | 127 | #: templates/oscar_invoices/invoice.html:165 128 | msgid "Shipping address" 129 | msgstr "" 130 | 131 | #: templates/oscar_invoices/invoice.html:174 132 | msgid "Billing address" 133 | msgstr "" 134 | 135 | #: templates/oscar_invoices/invoice.html:190 136 | msgid "Description" 137 | msgstr "" 138 | 139 | #: templates/oscar_invoices/invoice.html:191 140 | msgid "Quantity" 141 | msgstr "" 142 | 143 | #: templates/oscar_invoices/invoice.html:192 144 | msgid "Unit price" 145 | msgstr "" 146 | 147 | #: templates/oscar_invoices/invoice.html:193 148 | msgid "Total" 149 | msgstr "" 150 | 151 | #: templates/oscar_invoices/invoice.html:211 152 | msgid "Shipping charge" 153 | msgstr "" 154 | 155 | #: templates/oscar_invoices/invoice.html:219 156 | #: templates/oscar_invoices/invoice.html:229 157 | msgid "Discount" 158 | msgstr "" 159 | 160 | #: templates/oscar_invoices/invoice.html:237 161 | msgid "Order tax" 162 | msgstr "" 163 | 164 | #: templates/oscar_invoices/invoice.html:243 165 | msgid "Order total" 166 | msgstr "" 167 | 168 | #: templates/oscar_invoices/invoice.html:252 169 | msgid "Terms and conditions" 170 | msgstr "" 171 | 172 | #: templates/oscar_invoices/invoice.html:263 173 | msgid "Notes" 174 | msgstr "" 175 | -------------------------------------------------------------------------------- /oscar_invoices/abstract_models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.urls import reverse 4 | from django.utils.translation import gettext_lazy as _ 5 | from oscar.apps.address.abstract_models import AbstractAddress 6 | from oscar.core.loading import get_class 7 | from phonenumber_field.modelfields import PhoneNumberField 8 | 9 | from . import app_settings 10 | 11 | DocumentsStorage = get_class("oscar_invoices.storages", "DocumentsStorage") 12 | 13 | 14 | class AbstractLegalEntity(models.Model): 15 | """ 16 | Represents LegalEntity - merchant (company or individual) which we issue 17 | invoice on behalf of. 18 | """ 19 | shop_name = models.CharField(_('Shop name'), max_length=255, null=True, 20 | blank=True) 21 | business_name = models.CharField(_('Business name'), max_length=255, 22 | db_index=True) 23 | vat_number = models.CharField(_('VAT identification number'), max_length=20, 24 | null=True, blank=True) 25 | company_number = models.CharField(_('Company identification number'), 26 | max_length=20, null=True, blank=True) 27 | logo = models.ImageField( 28 | _('Logo'), upload_to=settings.OSCAR_IMAGE_FOLDER, max_length=255, 29 | null=True, blank=True) 30 | email = models.EmailField(_('Email'), null=True, blank=True) 31 | web_site = models.URLField(_('Website'), null=True, blank=True) 32 | iban = models.CharField(_("IBAN"), max_length=255, null=True, blank=True) 33 | bic = models.CharField(_("BIC"), max_length=255, null=True, blank=True) 34 | 35 | class Meta: 36 | abstract = True 37 | app_label = 'oscar_invoices' 38 | verbose_name = _('Legal Entity') 39 | verbose_name_plural = _('Legal Entities') 40 | 41 | def __str__(self): 42 | return self.business_name 43 | 44 | @property 45 | def has_addresses(self): 46 | return self.addresses.exists() 47 | 48 | 49 | class AbstractLegalEntityAddress(AbstractAddress): 50 | """ 51 | Represents Address of LegalEntity. 52 | 53 | Used in Invoices. 54 | """ 55 | legal_entity = models.ForeignKey( 56 | 'oscar_invoices.LegalEntity', 57 | on_delete=models.CASCADE, 58 | related_name='addresses', 59 | verbose_name=_('Legal Entity')) 60 | 61 | phone_number = PhoneNumberField(_('Phone number'), blank=True) 62 | fax_number = PhoneNumberField(_('Fax number'), blank=True) 63 | 64 | class Meta: 65 | abstract = True 66 | app_label = 'oscar_invoices' 67 | verbose_name = _('Legal Entity Address') 68 | verbose_name_plural = _('Legal Entity Addresses') 69 | 70 | 71 | class AbstractInvoice(models.Model): 72 | """ 73 | An Invoice. 74 | """ 75 | 76 | legal_entity = models.ForeignKey( 77 | 'oscar_invoices.LegalEntity', 78 | on_delete=models.CASCADE, 79 | related_name='invoices', 80 | verbose_name=_('Legal Entity')) 81 | 82 | number = models.CharField( 83 | _('Invoice number'), max_length=128, unique=True) 84 | 85 | order = models.OneToOneField( 86 | 'order.Order', verbose_name=_('Order'), related_name='invoice', 87 | null=True, blank=True, on_delete=models.SET_NULL) 88 | 89 | notes = models.TextField(_('Notes for invoice'), null=True, blank=False) 90 | 91 | document = models.FileField( 92 | _('Document'), upload_to=app_settings.OSCAR_INVOICES_UPLOAD_FOLDER, 93 | blank=True, null=True, max_length=255, storage=DocumentsStorage()) 94 | 95 | date = models.DateField(auto_now_add=True) 96 | 97 | class Meta: 98 | abstract = True 99 | app_label = 'oscar_invoices' 100 | verbose_name = _('Invoice') 101 | verbose_name_plural = _('Invoices') 102 | 103 | def get_absolute_url(self): 104 | return reverse('oscar_invoices:invoice', args=(self.pk,)) 105 | 106 | def __str__(self): 107 | if self.order: 108 | order_number = self.order.number 109 | return _( 110 | 'Invoice #%(invoice_number)s for order #%(order_number)s' 111 | ) % {'invoice_number': self.number, 'order_number': order_number} 112 | 113 | return _( 114 | 'Invoice #%(invoice_number)s') % {'invoice_number': self.number} 115 | -------------------------------------------------------------------------------- /oscar_invoices/locale/nl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-12-13 14:38+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: abstract_models.py:18 22 | msgid "Shop name" 23 | msgstr "Naam webshop" 24 | 25 | #: abstract_models.py:19 26 | msgid "Business name" 27 | msgstr "Bedrijfsnaam" 28 | 29 | #: abstract_models.py:20 30 | msgid "VAT identification number" 31 | msgstr "BTW nummer" 32 | 33 | #: abstract_models.py:22 34 | msgid "Logo" 35 | msgstr "Logo" 36 | 37 | #: abstract_models.py:23 templates/oscar_invoices/invoice.html:146 38 | msgid "Email" 39 | msgstr "E-mail" 40 | 41 | #: abstract_models.py:24 42 | msgid "Website" 43 | msgstr "Website" 44 | 45 | #: abstract_models.py:29 abstract_models.py:50 abstract_models.py:71 46 | msgid "Legal Entity" 47 | msgstr "Bedrijfsgegevens" 48 | 49 | #: abstract_models.py:30 50 | msgid "Legal Entities" 51 | msgstr "Bedrijfsgegevens" 52 | 53 | #: abstract_models.py:52 54 | msgid "Phone number" 55 | msgstr "Telefoonnummer" 56 | 57 | #: abstract_models.py:53 58 | msgid "Fax number" 59 | msgstr "Faxnummer" 60 | 61 | #: abstract_models.py:58 62 | msgid "Legal Entity Address" 63 | msgstr "Adres" 64 | 65 | #: abstract_models.py:59 66 | msgid "Legal Entity Addresses" 67 | msgstr "Adressen" 68 | 69 | #: abstract_models.py:74 70 | msgid "Invoice number" 71 | msgstr "Factuurnummer" 72 | 73 | #: abstract_models.py:77 templates/oscar_invoices/invoice.html:116 74 | msgid "Order" 75 | msgstr "Bestelling" 76 | 77 | #: abstract_models.py:80 78 | msgid "Notes for invoice" 79 | msgstr "Opmerkingen" 80 | 81 | #: abstract_models.py:83 82 | msgid "Document" 83 | msgstr "Document" 84 | 85 | #: abstract_models.py:89 templates/oscar_invoices/invoice.html:9 86 | msgid "Invoice" 87 | msgstr "Rekening" 88 | 89 | #: abstract_models.py:90 config.py:8 90 | msgid "Invoices" 91 | msgstr "Rekeningen" 92 | 93 | #: abstract_models.py:101 94 | #, python-format 95 | msgid "Invoice #%(invoice_number)s for order #%(order_number)s" 96 | msgstr "Factuur #%(invoice_number)s voor bestelling #%(order_number)s" 97 | 98 | #: abstract_models.py:105 99 | #, python-format 100 | msgid "Invoice %(invoice_number)s" 101 | msgstr "Factuur %(invoice_number)s" 102 | 103 | #: templates/oscar_invoices/invoice.html:115 104 | msgid "Original invoice" 105 | msgstr "Factuurnummer" 106 | 107 | #: templates/oscar_invoices/invoice.html:117 108 | msgid "Date" 109 | msgstr "Datum" 110 | 111 | #: templates/oscar_invoices/invoice.html:136 112 | msgid "VAT ID" 113 | msgstr "BTW nummer" 114 | 115 | #: templates/oscar_invoices/invoice.html:140 116 | msgid "Tel" 117 | msgstr "Tel" 118 | 119 | #: templates/oscar_invoices/invoice.html:143 120 | msgid "Fax" 121 | msgstr "Fax" 122 | 123 | #: templates/oscar_invoices/invoice.html:149 124 | msgid "Site" 125 | msgstr "Website" 126 | 127 | #: templates/oscar_invoices/invoice.html:165 128 | msgid "Shipping address" 129 | msgstr "Verzendadres" 130 | 131 | #: templates/oscar_invoices/invoice.html:174 132 | msgid "Billing address" 133 | msgstr "Factuuradres" 134 | 135 | #: templates/oscar_invoices/invoice.html:190 136 | msgid "Description" 137 | msgstr "Beschrijving" 138 | 139 | #: templates/oscar_invoices/invoice.html:191 140 | msgid "Quantity" 141 | msgstr "Aantal" 142 | 143 | #: templates/oscar_invoices/invoice.html:192 144 | msgid "Unit price" 145 | msgstr "Stuksprijs" 146 | 147 | #: templates/oscar_invoices/invoice.html:193 148 | msgid "Total" 149 | msgstr "Totaal" 150 | 151 | #: templates/oscar_invoices/invoice.html:211 152 | msgid "Shipping charge" 153 | msgstr "Verzendkosten" 154 | 155 | #: templates/oscar_invoices/invoice.html:219 156 | #: templates/oscar_invoices/invoice.html:229 157 | msgid "Discount" 158 | msgstr "Korting" 159 | 160 | #: templates/oscar_invoices/invoice.html:237 161 | msgid "Order tax" 162 | msgstr "BTW" 163 | 164 | #: templates/oscar_invoices/invoice.html:243 165 | msgid "Order total" 166 | msgstr "Totaal" 167 | 168 | #: templates/oscar_invoices/invoice.html:252 169 | msgid "Terms and conditions" 170 | msgstr "Voorwaarden" 171 | 172 | #: templates/oscar_invoices/invoice.html:263 173 | msgid "Notes" 174 | msgstr "Opmerkingen" 175 | -------------------------------------------------------------------------------- /oscar_invoices/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.6 on 2018-06-07 12:09 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import oscar.models.fields 6 | import phonenumber_field.modelfields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('address', '0001_initial'), 15 | ('order', '0001_initial'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Invoice', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('number', models.CharField(max_length=128, verbose_name='Invoice number')), 24 | ('notes', models.TextField(null=True, verbose_name='Notes for invoice')), 25 | ('document', models.FileField(blank=True, max_length=255, null=True, upload_to='invoices/%Y/%m/', verbose_name='Document')), 26 | ], 27 | options={ 28 | 'verbose_name': 'Invoice', 29 | 'verbose_name_plural': 'Invoices', 30 | 'abstract': False, 31 | }, 32 | ), 33 | migrations.CreateModel( 34 | name='LegalEntity', 35 | fields=[ 36 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 37 | ('shop_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Shop name')), 38 | ('business_name', models.CharField(db_index=True, max_length=255, verbose_name='Business name')), 39 | ('vat_number', models.CharField(max_length=20, verbose_name='VAT identification number')), 40 | ('logo', models.ImageField(blank=True, max_length=255, null=True, upload_to='images/products/%Y/%m/', verbose_name='Logo')), 41 | ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')), 42 | ('web_site', models.URLField(blank=True, null=True, verbose_name='Website')), 43 | ], 44 | options={ 45 | 'verbose_name': 'Legal Entity', 46 | 'verbose_name_plural': 'Legal Entities', 47 | 'abstract': False, 48 | }, 49 | ), 50 | migrations.CreateModel( 51 | name='LegalEntityAddress', 52 | fields=[ 53 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 54 | ('title', models.CharField(blank=True, choices=[('Mr', 'Mr'), ('Miss', 'Miss'), ('Mrs', 'Mrs'), ('Ms', 'Ms'), ('Dr', 'Dr')], max_length=64, verbose_name='Title')), 55 | ('first_name', models.CharField(blank=True, max_length=255, verbose_name='First name')), 56 | ('last_name', models.CharField(blank=True, max_length=255, verbose_name='Last name')), 57 | ('line1', models.CharField(max_length=255, verbose_name='First line of address')), 58 | ('line2', models.CharField(blank=True, max_length=255, verbose_name='Second line of address')), 59 | ('line3', models.CharField(blank=True, max_length=255, verbose_name='Third line of address')), 60 | ('line4', models.CharField(blank=True, max_length=255, verbose_name='City')), 61 | ('state', models.CharField(blank=True, max_length=255, verbose_name='State/County')), 62 | ('postcode', oscar.models.fields.UppercaseCharField(blank=True, max_length=64, verbose_name='Post/Zip-code')), 63 | ('search_text', models.TextField(editable=False, verbose_name='Search text - used only for searching addresses')), 64 | ('phone_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, verbose_name='Phone number')), 65 | ('fax_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, verbose_name='Fax number')), 66 | ('country', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='address.Country', verbose_name='Country')), 67 | ('legal_entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='oscar_invoices.LegalEntity', verbose_name='Legal Entity')), 68 | ], 69 | options={ 70 | 'verbose_name': 'Legal Entity Address', 71 | 'verbose_name_plural': 'Legal Entity Addresses', 72 | 'abstract': False, 73 | }, 74 | ), 75 | migrations.AddField( 76 | model_name='invoice', 77 | name='legal_entity', 78 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='oscar_invoices.LegalEntity', verbose_name='Legal Entity'), 79 | ), 80 | migrations.AddField( 81 | model_name='invoice', 82 | name='order', 83 | field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoice', to='order.Order', verbose_name='Order'), 84 | ), 85 | ] 86 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | 18 | import django 19 | 20 | project_folder = os.path.realpath(os.path.join(os.path.dirname(__file__), '../..')) 21 | sys.path.insert(0, project_folder) 22 | 23 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'sandbox.settings') 24 | 25 | django.setup() 26 | 27 | # -- Project information ----------------------------------------------------- 28 | 29 | project = 'django-oscar-invoices' 30 | copyright = '2018, Alexander Gaevsky, Basil Dubyk' 31 | author = 'Alexander Gaevsky, Basil Dubyk' 32 | 33 | # The short X.Y version 34 | version = '' 35 | # The full version, including alpha/beta/rc tags 36 | release = '0.1' 37 | 38 | 39 | # -- General configuration --------------------------------------------------- 40 | 41 | # If your documentation needs a minimal Sphinx version, state it here. 42 | # 43 | # needs_sphinx = '1.0' 44 | 45 | # Add any Sphinx extension module names here, as strings. They can be 46 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 47 | # ones. 48 | extensions = [ 49 | 'sphinx.ext.autodoc', 50 | 'sphinx.ext.intersphinx', 51 | 'sphinx.ext.imgmath', 52 | 'sphinx.ext.ifconfig', 53 | 'sphinx.ext.viewcode', 54 | ] 55 | 56 | # Add any paths that contain templates here, relative to this directory. 57 | templates_path = ['_templates'] 58 | 59 | # The suffix(es) of source filenames. 60 | # You can specify multiple suffix as a list of string: 61 | # 62 | # source_suffix = ['.rst', '.md'] 63 | source_suffix = '.rst' 64 | 65 | # The master toctree document. 66 | master_doc = 'index' 67 | 68 | # The language for content autogenerated by Sphinx. Refer to documentation 69 | # for a list of supported languages. 70 | # 71 | # This is also used if you do content translation via gettext catalogs. 72 | # Usually you set "language" from the command line for these cases. 73 | language = None 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | # This pattern also affects html_static_path and html_extra_path . 78 | exclude_patterns = [] 79 | 80 | # The name of the Pygments (syntax highlighting) style to use. 81 | pygments_style = 'sphinx' 82 | 83 | 84 | # -- Options for HTML output ------------------------------------------------- 85 | 86 | # The theme to use for HTML and HTML Help pages. See the documentation for 87 | # a list of builtin themes. 88 | # 89 | html_theme = 'default' 90 | 91 | # Theme options are theme-specific and customize the look and feel of a theme 92 | # further. For a list of options available for each theme, see the 93 | # documentation. 94 | # 95 | # html_theme_options = {} 96 | 97 | # Add any paths that contain custom static files (such as style sheets) here, 98 | # relative to this directory. They are copied after the builtin static files, 99 | # so a file named "default.css" will overwrite the builtin "default.css". 100 | html_static_path = ['_static'] 101 | 102 | # Custom sidebar templates, must be a dictionary that maps document names 103 | # to template names. 104 | # 105 | # The default sidebars (for documents that don't match any pattern) are 106 | # defined by theme itself. Builtin themes are using these templates by 107 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 108 | # 'searchbox.html']``. 109 | # 110 | # html_sidebars = {} 111 | 112 | 113 | # -- Options for HTMLHelp output --------------------------------------------- 114 | 115 | # Output file base name for HTML help builder. 116 | htmlhelp_basename = 'django-oscar-invoicesdoc' 117 | 118 | 119 | # -- Options for LaTeX output ------------------------------------------------ 120 | 121 | latex_elements = { 122 | # The paper size ('letterpaper' or 'a4paper'). 123 | # 124 | # 'papersize': 'letterpaper', 125 | 126 | # The font size ('10pt', '11pt' or '12pt'). 127 | # 128 | # 'pointsize': '10pt', 129 | 130 | # Additional stuff for the LaTeX preamble. 131 | # 132 | # 'preamble': '', 133 | 134 | # Latex figure (float) alignment 135 | # 136 | # 'figure_align': 'htbp', 137 | } 138 | 139 | # Grouping the document tree into LaTeX files. List of tuples 140 | # (source start file, target name, title, 141 | # author, documentclass [howto, manual, or own class]). 142 | latex_documents = [ 143 | (master_doc, 'django-oscar-invoices.tex', 'django-oscar-invoices Documentation', 144 | 'Alexander Gaevsky, Basil Dubyk', 'manual'), 145 | ] 146 | 147 | 148 | # -- Options for manual page output ------------------------------------------ 149 | 150 | # One entry per manual page. List of tuples 151 | # (source start file, name, description, authors, manual section). 152 | man_pages = [ 153 | (master_doc, 'django-oscar-invoices', 'django-oscar-invoices Documentation', 154 | [author], 1) 155 | ] 156 | 157 | 158 | # -- Options for Texinfo output ---------------------------------------------- 159 | 160 | # Grouping the document tree into Texinfo files. List of tuples 161 | # (source start file, target name, title, author, 162 | # dir menu entry, description, category) 163 | texinfo_documents = [ 164 | (master_doc, 'django-oscar-invoices', 'django-oscar-invoices Documentation', 165 | author, 'django-oscar-invoices', 'One line description of project.', 166 | 'Miscellaneous'), 167 | ] 168 | 169 | 170 | # -- Extension configuration ------------------------------------------------- 171 | 172 | # -- Options for intersphinx extension --------------------------------------- 173 | 174 | # Example configuration for intersphinx: refer to the Python standard library. 175 | intersphinx_mapping = {'https://docs.python.org/': None} -------------------------------------------------------------------------------- /tests/test_invoices.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import threading 4 | import time 5 | from datetime import date 6 | 7 | import pytest 8 | from django.conf import settings 9 | from django.test import TransactionTestCase 10 | from mock import patch 11 | from oscar.core.loading import get_class, get_model 12 | from oscar.test.factories import CountryFactory, UserFactory, create_order 13 | from oscar.test.testcases import WebTestCase 14 | from oscar.test.utils import run_concurrently 15 | 16 | from oscar_invoices import app_settings 17 | from oscar_invoices.utils import InvoiceCreator 18 | 19 | from ._site.apps.custom_invoices.models import CustomInvoice 20 | from .factories import LegalEntityAddressFactory, LegalEntityFactory 21 | 22 | Invoice = get_model('oscar_invoices', 'Invoice') 23 | LegalEntity = get_model('oscar_invoices', 'LegalEntity') 24 | LegalEntityAddress = get_model('oscar_invoices', 'LegalEntityAddress') 25 | Partner = get_model('partner', 'Partner') 26 | ProductClass = get_model('catalogue', 'ProductClass') 27 | 28 | 29 | OSCAR_INVOICES_FOLDER_FORMATTED = 'invoices/{0}/{1:02d}/'.format(date.today().year, date.today().month) 30 | FULL_PATH_TO_INVOICES = os.path.join(app_settings.OSCAR_INVOICES_DOCUMENTS_ROOT, OSCAR_INVOICES_FOLDER_FORMATTED) 31 | 32 | 33 | class TestInvoiceMixin: 34 | 35 | def setUp(self): 36 | super().setUp() 37 | self.user = UserFactory() 38 | 39 | LegalEntityAddressFactory( 40 | legal_entity=LegalEntityFactory(), 41 | country=CountryFactory(), 42 | ) 43 | 44 | def tearDown(self): 45 | super().tearDown() 46 | # Remove `OSCAR_INVOICE_FOLDER` after each test 47 | if os.path.exists(FULL_PATH_TO_INVOICES): 48 | shutil.rmtree(FULL_PATH_TO_INVOICES) 49 | 50 | 51 | class TestInvoice(TestInvoiceMixin, WebTestCase): 52 | 53 | def setUp(self): 54 | super().setUp() 55 | self.order = create_order(number='000042', user=self.user) 56 | 57 | def _test_invoice_is_created(self, order_number='000043'): 58 | order = create_order(number=order_number, user=self.user) 59 | InvoiceCreator().create_invoice(order) 60 | assert Invoice.objects.exists() 61 | invoice = Invoice.objects.first() 62 | return invoice 63 | 64 | def test_invoice_cannot_be_created_without_legal_entity(self): 65 | LegalEntity.objects.all().delete() 66 | 67 | assert not Invoice.objects.exists() 68 | InvoiceCreator().create_invoice(self.order) 69 | assert not Invoice.objects.exists() 70 | 71 | def test_invoice_cannot_be_created_without_legal_entity_address(self): 72 | LegalEntityAddress.objects.all().delete() 73 | 74 | assert not Invoice.objects.exists() 75 | InvoiceCreator().create_invoice(self.order) 76 | self.assertFalse(Invoice.objects.exists()) 77 | 78 | def test_invoice_can_be_created_with_legal_entity_and_its_address(self): 79 | assert not Invoice.objects.exists() 80 | InvoiceCreator().create_invoice(self.order) 81 | assert Invoice.objects.exists() 82 | invoice = Invoice.objects.first() 83 | # Document created and saved 84 | assert invoice.document is not None 85 | 86 | def test_invoice_creation_based_on_settings(self): 87 | invoice = self._test_invoice_is_created() 88 | # Document created and saved 89 | assert invoice.document is not None 90 | 91 | def test_invoice_document_is_not_accessible_via_url(self): 92 | invoice = self._test_invoice_is_created() 93 | 94 | another_user = UserFactory(username='another_user') 95 | staff_user = UserFactory(is_staff=True) 96 | superuser = UserFactory(is_superuser=True) 97 | 98 | # Invoice document is not accessible via url for any user 99 | for user in [self.user, another_user, staff_user, superuser]: 100 | with pytest.raises(ValueError, match='This file is not accessible via a URL.'): 101 | self.app.get(invoice.document.url, user=user) 102 | 103 | def test_invoice_was_saved_to_correct_folder(self): 104 | order_number = 'TEST_number_000d5' 105 | invoice = self._test_invoice_is_created(order_number=order_number) 106 | 107 | file_names = os.listdir(FULL_PATH_TO_INVOICES) 108 | assert len(file_names) == 1 # Only one file here 109 | invoice_file_name = 'invoice_{}.html'.format(invoice.number) 110 | assert invoice_file_name in file_names 111 | 112 | if os.path.exists(settings.MEDIA_ROOT): 113 | media_file_names = os.listdir(settings.MEDIA_ROOT) 114 | assert invoice_file_name not in media_file_names 115 | 116 | def test_default_invoice_model_used(self): 117 | order_number = 'TEST_number_000d6' 118 | invoice = self._test_invoice_is_created(order_number=order_number) 119 | assert isinstance(invoice, Invoice) 120 | 121 | @patch('oscar_invoices.app_settings.OSCAR_INVOICES_INVOICE_MODEL', 'custom_invoices.CustomInvoice') 122 | def test_custom_invoice_model_used(self, *args, **kwargs): 123 | order_number = 'TEST_number_000d6' 124 | order = create_order(number=order_number, user=self.user) 125 | InvoiceCreator().create_invoice(order) 126 | assert CustomInvoice.objects.exists() 127 | 128 | def test_str_method_of_invoice_model_instance(self): 129 | """ 130 | Checks correct representation of `Invoice` instance 131 | (e.g. in invoices list in the admin site). 132 | """ 133 | order_number = '0000042' 134 | order = create_order(number=order_number, user=self.user) 135 | invoice = InvoiceCreator().create_invoice(order) 136 | assert str(invoice) == 'Invoice #{} for order #{}'.format(invoice.number, order_number) 137 | 138 | def test_str_method_of_invoice_model_instance_when_order_is_deleted(self): 139 | """ 140 | Checks correct representation of `Invoice` instance 141 | (E.g. in invoices list in the admin site) when related order 142 | is deleted. 143 | """ 144 | order_number = '0000043' 145 | order = create_order(number=order_number, user=self.user) 146 | invoice = InvoiceCreator().create_invoice(order) 147 | order.delete() 148 | invoice.refresh_from_db() 149 | assert str(invoice) == 'Invoice #{}'.format(invoice.number) 150 | 151 | def test_invoice_creator_loading(self): 152 | assert get_class('oscar_invoices.utils', 'InvoiceCreator') == InvoiceCreator 153 | 154 | 155 | class TestConcurrentInvoiceCreation(TestInvoiceMixin, TransactionTestCase): 156 | 157 | def setUp(self): 158 | super().setUp() 159 | # Next needed to prevent `MultipleObjectsReturned` error during concurrent invoices creation 160 | ProductClass.objects.create(name='Dùmϻϒ item class') 161 | Partner.objects.create(name='') 162 | 163 | def test_concurrent_invoice_creation(self): 164 | num_threads = 5 165 | creator = InvoiceCreator() 166 | 167 | org_create_invoice = InvoiceCreator.create_invoice 168 | 169 | def new_create_invoice(order, **kwargs): 170 | time.sleep(0.5) 171 | return org_create_invoice(creator, order, **kwargs) 172 | 173 | def worker(): 174 | order_number = threading.current_thread().name 175 | order = create_order(number=order_number, user=self.user) 176 | new_create_invoice(order) 177 | 178 | exceptions = run_concurrently(worker, num_threads=num_threads) 179 | 180 | assert len(exceptions) == 0 181 | assert Invoice.objects.count() == 5 182 | -------------------------------------------------------------------------------- /sandbox/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from oscar.defaults import * # noqa 4 | 5 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | 8 | 9 | # Quick-start development settings - unsuitable for production 10 | # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ 11 | 12 | # SECURITY WARNING: keep the secret key used in production secret! 13 | SECRET_KEY = '0f@pxi5i$32@qc5lp*thcs=9mm2%j-qeuimat$ga6nbw#)tk2x' 14 | 15 | # SECURITY WARNING: don't run with debug turned on in production! 16 | DEBUG = True 17 | 18 | ALLOWED_HOSTS = [] 19 | 20 | 21 | # Application definition 22 | 23 | INSTALLED_APPS = [ 24 | 'django.contrib.admin', 25 | 'django.contrib.auth', 26 | 'django.contrib.contenttypes', 27 | 'django.contrib.sessions', 28 | 'django.contrib.messages', 29 | 'django.contrib.staticfiles', 30 | 31 | 'django.contrib.sites', 32 | 'django.contrib.flatpages', 33 | 34 | 'oscar.config.Shop', 35 | 'oscar.apps.analytics.apps.AnalyticsConfig', 36 | 'oscar.apps.checkout.apps.CheckoutConfig', 37 | 'oscar.apps.address.apps.AddressConfig', 38 | 'oscar.apps.shipping.apps.ShippingConfig', 39 | 'oscar.apps.catalogue.apps.CatalogueConfig', 40 | 'oscar.apps.catalogue.reviews.apps.CatalogueReviewsConfig', 41 | 'oscar.apps.communication.apps.CommunicationConfig', 42 | 'oscar.apps.partner.apps.PartnerConfig', 43 | 'oscar.apps.basket.apps.BasketConfig', 44 | 'oscar.apps.payment.apps.PaymentConfig', 45 | 'oscar.apps.offer.apps.OfferConfig', 46 | 'oscar.apps.order.apps.OrderConfig', 47 | 'oscar.apps.customer.apps.CustomerConfig', 48 | 'oscar.apps.search.apps.SearchConfig', 49 | 'oscar.apps.voucher.apps.VoucherConfig', 50 | 'oscar.apps.wishlists.apps.WishlistsConfig', 51 | 'oscar.apps.dashboard.apps.DashboardConfig', 52 | 'oscar.apps.dashboard.reports.apps.ReportsDashboardConfig', 53 | 'oscar.apps.dashboard.users.apps.UsersDashboardConfig', 54 | 'oscar.apps.dashboard.orders.apps.OrdersDashboardConfig', 55 | 'oscar.apps.dashboard.catalogue.apps.CatalogueDashboardConfig', 56 | 'oscar.apps.dashboard.offers.apps.OffersDashboardConfig', 57 | 'oscar.apps.dashboard.partners.apps.PartnersDashboardConfig', 58 | 'oscar.apps.dashboard.pages.apps.PagesDashboardConfig', 59 | 'oscar.apps.dashboard.ranges.apps.RangesDashboardConfig', 60 | 'oscar.apps.dashboard.reviews.apps.ReviewsDashboardConfig', 61 | 'oscar.apps.dashboard.vouchers.apps.VouchersDashboardConfig', 62 | 'oscar.apps.dashboard.communications.apps.CommunicationsDashboardConfig', 63 | 'oscar.apps.dashboard.shipping.apps.ShippingDashboardConfig', 64 | 65 | # 3rd-party apps that oscar depends on 66 | 'widget_tweaks', 67 | 'haystack', 68 | 'treebeard', 69 | 'sorl.thumbnail', 70 | 'django_tables2', 71 | 72 | 'oscar_invoices.apps.InvoicesConfig', 73 | 'test_app', 74 | ] 75 | 76 | MIDDLEWARE = [ 77 | 'django.middleware.security.SecurityMiddleware', 78 | 'django.contrib.sessions.middleware.SessionMiddleware', 79 | 'django.middleware.common.CommonMiddleware', 80 | 'django.middleware.csrf.CsrfViewMiddleware', 81 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 82 | 'django.contrib.messages.middleware.MessageMiddleware', 83 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 84 | 'oscar.apps.basket.middleware.BasketMiddleware', 85 | ] 86 | 87 | ROOT_URLCONF = 'urls' 88 | 89 | TEMPLATES = [ 90 | { 91 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 92 | 'DIRS': [ 93 | os.path.join(BASE_DIR, 'templates'), 94 | ], 95 | 'APP_DIRS': True, 96 | 'OPTIONS': { 97 | 'context_processors': [ 98 | 'django.template.context_processors.debug', 99 | 'django.template.context_processors.request', 100 | 'django.contrib.auth.context_processors.auth', 101 | 'django.template.context_processors.i18n', 102 | 'django.contrib.messages.context_processors.messages', 103 | 104 | 'oscar.apps.search.context_processors.search_form', 105 | 'oscar.apps.checkout.context_processors.checkout', 106 | 'oscar.apps.communication.notifications.context_processors.notifications', 107 | 'oscar.core.context_processors.metadata', 108 | ], 109 | }, 110 | }, 111 | ] 112 | 113 | LOGGING = { 114 | 'version': 1, 115 | 'disable_existing_loggers': True, 116 | 'formatters': { 117 | 'verbose': { 118 | 'format': '%(levelname)s %(asctime)s %(module)s %(message)s', 119 | }, 120 | 'simple': { 121 | 'format': '[%(asctime)s] %(message)s' 122 | }, 123 | }, 124 | 'root': { 125 | 'level': 'DEBUG', 126 | 'handlers': ['console'], 127 | }, 128 | 'handlers': { 129 | 'null': { 130 | 'level': 'DEBUG', 131 | 'class': 'logging.NullHandler', 132 | }, 133 | 'console': { 134 | 'level': 'DEBUG', 135 | 'class': 'logging.StreamHandler', 136 | 'formatter': 'simple' 137 | }, 138 | }, 139 | 'loggers': { 140 | 'oscar': { 141 | 'level': 'DEBUG', 142 | 'propagate': True, 143 | }, 144 | 'oscar_invoices': { 145 | 'handlers': ['console'], 146 | 'level': 'DEBUG', 147 | }, 148 | 149 | # Django loggers 150 | 'django': { 151 | 'handlers': ['null'], 152 | 'propagate': True, 153 | 'level': 'INFO', 154 | }, 155 | 'django.request': { 156 | 'handlers': ['console'], 157 | 'level': 'ERROR', 158 | 'propagate': True, 159 | }, 160 | 'django.db.backends': { 161 | 'level': 'WARNING', 162 | 'propagate': True, 163 | }, 164 | } 165 | } 166 | 167 | WSGI_APPLICATION = 'wsgi.application' 168 | 169 | 170 | # Database 171 | # https://docs.djangoproject.com/en/2.0/ref/settings/#databases 172 | 173 | DATABASES = { 174 | 'default': { 175 | 'ENGINE': 'django.db.backends.sqlite3', 176 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 177 | } 178 | } 179 | 180 | 181 | # Password validation 182 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 183 | 184 | AUTH_PASSWORD_VALIDATORS = [ 185 | { 186 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 187 | }, 188 | { 189 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 190 | }, 191 | { 192 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 193 | }, 194 | { 195 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 196 | }, 197 | ] 198 | 199 | 200 | # Internationalization 201 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 202 | 203 | LANGUAGE_CODE = 'en-us' 204 | 205 | TIME_ZONE = 'UTC' 206 | 207 | USE_I18N = True 208 | 209 | USE_L10N = True 210 | 211 | USE_TZ = True 212 | 213 | 214 | # Static files (CSS, JavaScript, Images) 215 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 216 | 217 | STATIC_URL = '/static/' 218 | 219 | MEDIA_URL = '/media/' 220 | 221 | MEDIA_ROOT = os.path.join(BASE_DIR, 'sandbox', 'media') 222 | 223 | AUTHENTICATION_BACKENDS = ( 224 | 'oscar.apps.customer.auth_backends.EmailBackend', 225 | 'django.contrib.auth.backends.ModelBackend', 226 | ) 227 | 228 | HAYSTACK_CONNECTIONS = { 229 | 'default': { 230 | 'ENGINE': 'haystack.backends.simple_backend.SimpleEngine', 231 | }, 232 | } 233 | 234 | SITE_ID = 1 235 | 236 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 237 | -------------------------------------------------------------------------------- /oscar_invoices/templates/oscar_invoices/invoice.html: -------------------------------------------------------------------------------- 1 | {% load i18n currency_filters %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% trans "Invoice" %} 10 | 11 | 107 | 108 | 109 | 110 |
111 | 112 | {% block header %} 113 | 114 | 119 | 120 | {% endblock %} 121 | 122 | {% block legal_entity %} 123 | 124 | 161 | 162 | {% endblock %} 163 | 164 | {% block addresses %} 165 | 166 | 190 | 191 | {% endblock %} 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | {% block order_lines %} 202 | {% for line in order.lines.all %} 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | {% endfor %} 211 | {% endblock %} 212 | 213 | {% block order_totals %} 214 | 215 | 216 | 217 | 218 | 219 | 220 | {% if order.basket_discounts %} 221 | {% for discount in order.basket_discounts %} 222 | 223 | 224 | 225 | 226 | 227 | {% endfor %} 228 | {% endif %} 229 | 230 | {% if order.has_shipping_discounts %} 231 | {% for discount in order.shipping_discounts %} 232 | 233 | 234 | 235 | 236 | 237 | {% endfor %} 238 | {% endif %} 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | {% endblock %} 252 | 253 | {% block terms_and_conditions %} 254 | {% if terms_and_conditions %} 255 | 256 | 260 | 261 | {% endif %} 262 | {% endblock %} 263 | 264 | {% block additional_data %} 265 | {% if additional_data %} 266 | 267 | 271 | 272 | {% endif %} 273 | {% endblock %} 274 |
115 |

{% trans "Original invoice" %} #{{ invoice.number }}


116 | {% trans "Order" %} #{{ order.number }}
117 | {% trans "Date" %}: {{ order.date_placed|date:'d.m.Y' }}
118 |
125 | 126 | 127 | 134 | 158 | 159 |
128 | {% if legal_entity.logo %} 129 | 130 | {% else %} 131 | {{ legal_entity.shop_name }} 132 | {% endif %} 133 | 135 | {{ legal_entity.business_name }}
136 | {% if legal_entity.vat_number %} 137 | {% trans "VAT ID" %}: {{ legal_entity.vat_number }}
138 | {% endif %} 139 | {% if legal_entity.company_number %} 140 | {% trans "Company ID" %}: {{ legal_entity.company_number }}
141 | {% endif %} 142 |
143 | {{ legal_entity_address.summary }}
144 | {% if legal_entity_address.phone_number %} 145 | {% trans "Tel" %}: {{ legal_entity_address.phone_number }}
146 | {% endif %} 147 | {% if legal_entity_address.fax_number %} 148 | {% trans "Fax" %}: {{ legal_entity_address.fax_number }}
149 | {% endif %} 150 | {% if legal_entity_address.email %} 151 | {% trans "Email" %}: {{ legal_entity_address.email }}
152 | {% endif %} 153 | {% if legal_entity_address.web_site %} 154 | {% trans "Site" %}: {{ legal_entity_address.web_site }}
155 | {% endif %} 156 |
157 |
160 |
167 | 168 | 169 | 177 | {% if order.billing_address %} 178 | 186 | {% endif %} 187 | 188 |
170 |

{% trans "Shipping address" %}:

171 |
172 | {% for field in order.shipping_address.active_address_fields %} 173 | {{ field }}
174 | {% endfor %} 175 |
176 |
179 |

{% trans "Billing address" %}:

180 |
181 | {% for field in order.billing_address.active_address_fields %} 182 | {{ field }}
183 | {% endfor %} 184 |
185 |
189 |
#{% trans "Description" %}{% trans "Quantity" %}{% trans "Unit price" %}{% trans "Total" %}
{{ forloop.counter }}{{ line.description }}{{ line.quantity }}{{ line.unit_price_excl_tax|currency:order.currency }}{{ line.line_price_excl_tax|currency:order.currency }}
{% trans "Shipping charge" %}:{{ order.shipping_excl_tax|currency:order.currency }}
{% trans "Discount" %} {{ discount.offer }}:- {{ discount.amount|currency:order.currency }}
{% trans "Discount" %} {{ discount.offer }}:- {{ discount.amount|currency:order.currency }}
{% trans "Order tax" %}:{{ order.total_tax|currency:order.currency }}
{% trans "Order total" %}:{{ order.total_incl_tax|currency:order.currency }}
257 |

{% trans "Terms and conditions" %}:

258 | {{ terms_and_conditions }} 259 |
268 |

{% trans "Notes" %}:

269 | {{ additional_data }} 270 |
275 |
276 | 277 | --------------------------------------------------------------------------------