diff --git a/.gitignore b/.gitignore index 6af2616..7912426 100644 --- a/.gitignore +++ b/.gitignore @@ -159,6 +159,7 @@ cython_debug/ kernel/ manage.py +media/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore diff --git a/docs/_static/9.png b/docs/_static/9.png index 369b627..f0e161e 100644 Binary files a/docs/_static/9.png and b/docs/_static/9.png differ diff --git a/docs/source/getting_started/admin.rst b/docs/source/getting_started/admin.rst index aab30eb..7962141 100644 --- a/docs/source/getting_started/admin.rst +++ b/docs/source/getting_started/admin.rst @@ -6,7 +6,7 @@ The admin layer customizes the Django admin interface to manage invoices efficie Managing Invoices ----------------- -In the Django admin interface, you can manage various aspects of invoices, including creating, editing, and deleting them. +In the Django admin interface, you can manage various aspects of invoices, including creating, editing, deleting, and downloading them as PDFs. Viewing Invoices ---------------- @@ -38,7 +38,6 @@ To create a new invoice: 4. Select design elements like logo, background, signature, and stamp. - 5. Choose a template for the invoice. The system comes with three pre-defined templates, but you can create your own custom templates as well. .. image:: ../../_static/4.png @@ -47,7 +46,7 @@ To create a new invoice: 6. After saving the invoice, you can add custom columns to include additional details like delivery date or warranty period. .. image:: ../../_static/5.png - :alt: Adding a custom columns + :alt: Adding a Custom Column .. note:: Custom columns can only be added after the invoice has been created. @@ -68,22 +67,28 @@ To show the details of an existing invoice: .. image:: ../../_static/7.png :alt: Showing the Selected Invoice - + .. image:: ../../_static/8.png :alt: Print the Selected Invoice Exporting Invoices ------------------ -The Django admin interface also allows you to export invoices as HTML files bundled in a ZIP archive. +The Django admin interface allows you to export invoices as HTML files bundled in a ZIP archive, or as PDFs. + +Downloading Selected Invoices as PDF +------------------------------------ 1. Select one or more invoices from the list. -2. Choose the `Download selected report as ZIP file` action from the dropdown menu. +2. Choose the `Download selected invoices as PDF` action from the dropdown menu. .. image:: ../../_static/9.png - :alt: Exporting Invoices + :alt: Exporting Invoices as PDF -3. The ZIP file will include the invoice(s) and any associated static files like logos or signatures. +3. The system will generate the selected invoices in PDF format and download them to your local machine. .. image:: ../../_static/10.png - :alt: Downloaded ZIP File + :alt: Exporting Invoices as ZIP + +.. note:: + If you select more than file it will convert the pdf to zip diff --git a/docs/source/getting_started/models.rst b/docs/source/getting_started/models.rst index e15c81a..c90e6d0 100644 --- a/docs/source/getting_started/models.rst +++ b/docs/source/getting_started/models.rst @@ -11,72 +11,63 @@ The `Invoice` model represents an invoice in the system, capturing essential det Fields ^^^^^^ -- `invoice_date`: The date when the invoice was created. -- `customer_name`: The name of the customer. -- `customer_email`: The email of the customer. -- `status`: The current status of the invoice (e.g., Paid, Unpaid). -- `notes`: Additional notes regarding the invoice. -- `category`: The category associated with this invoice. -- `due_date`: The date by which the invoice should be paid. -- `logo`: The logo displayed on the invoice. -- `signature`: The signature image for the invoice. -- `stamp`: The stamp image for the invoice. -- `template_choice`: The template used for rendering the invoice. - -InvoiceCategory ---------------- - -The `InvoiceCategory` model represents categories that can be associated with invoices, helping to organize and classify them. - -Fields -^^^^^^ - -- `title`: The title of the category. -- `description`: A description of the category. - -InvoiceItem +- **invoice_date**: The date when the invoice was created. +- **customer_name**: The name of the customer. +- **customer_email**: The email of the customer. +- **status**: The current status of the invoice (e.g., Paid, Unpaid). +- **notes**: Additional notes regarding the invoice. +- **category**: JSONField that allows storing various categories in a dynamic format, such as Terms & Conditions or additional notes. +- **due_date**: The date by which the invoice should be paid. +- **logo**: An image field representing the company logo on the invoice. +- **signature**: The signature image for the invoice. +- **stamp**: The stamp image for the invoice. +- **tracking_code**: Enter the first 3-4 characters of the tracking code. The full code will be auto-generated as + + . +- **template_choice**: Specifies the template used for rendering the invoice. +- **contacts**: JSONField that stores the contact details (e.g., email, phone number) of the customer in a flexible format. + +Item ----------- -The `InvoiceItem` model represents individual items within an invoice, detailing the products or services provided. +The `Item` model represents individual items within an invoice, detailing the products or services provided. Fields ^^^^^^ -- `description`: Description of the item. -- `quantity`: The quantity of the item. -- `unit_price`: The price per unit of the item. -- `total_price`: The total price for this item (calculated as quantity * unit price). -- `invoice`: The invoice associated with this item. +- **description**: Description of the item. +- **quantity**: The quantity of the item. +- **unit_price**: The price per unit of the item. +- **total_price**: The total price for this item (calculated as quantity * unit price). +- **invoice**: A ForeignKey linking the item to its associated invoice. -InvoiceColumn +Column ------------- -The `InvoiceColumn` model represents custom columns that can be added to individual items in an invoice. +The `Column` model represents custom columns that can be added to individual items in an invoice. Fields ^^^^^^ -- `priority`: The priority associated with each custom column. -- `column_name`: The name of the custom column (e.g., 'Delivery Date', 'Warranty Period'). -- `value`: The value for the custom column in the specific invoice. -- `invoice`: The invoice associated with this custom column. -- `item`: The item associated with this custom column. +- **priority**: The display priority for this column. +- **column_name**: The name of the custom column (e.g., 'Delivery Date', 'Warranty Period'). +- **value**: The value for the custom column in the specific invoice. +- **invoice**: The invoice associated with this custom column. +- **item**: The item associated with this custom column. Expense ------------- +------- -The `Expense` model represents the total amount for an invoice, including calculations for tax, discounts, and the final total. +The `Expense` model represents calculations for subtotals, taxes, discounts, and totals for an invoice. Fields ^^^^^^ -- `subtotal`: The sum of all item totals. -- `tax_percentage`: The tax percentage applied to the invoice. -- `discount_percentage`: The discount percentage applied to the invoice. -- `tax_amount`: The calculated tax amount. -- `discount_amount`: The calculated discount amount. -- `total_amount`: The final total after applying tax and discount. -- `invoice`: The invoice associated with this total. +- **subtotal**: The sum of all item totals. +- **tax_percentage**: The tax percentage applied to the invoice. +- **tax_amount**: The calculated tax amount. +- **discount_percentage**: The discount percentage applied to the invoice. +- **discount_amount**: The calculated discount amount. +- **total_amount**: The final total after applying tax and discount. +- **invoice**: A ForeignKey linking the expense to its associated invoice. Admin Integration ----------------- @@ -86,13 +77,7 @@ To integrate these models into the Django admin interface, register them in the .. code-block:: python from django.contrib import admin - from sage_invoice.models import ( - Invoice, - InvoiceCategory, - InvoiceItem, - InvoiceColumn, - Expense, - ) + from sage_invoice.models import Invoice, Item, Column, Expense @admin.register(Invoice) @@ -100,18 +85,13 @@ To integrate these models into the Django admin interface, register them in the list_display = ["title", "invoice_date", "customer_name", "status"] - @admin.register(InvoiceCategory) - class InvoiceCategoryAdmin(admin.ModelAdmin): - list_display = ["title", "description"] - - - @admin.register(InvoiceItem) - class InvoiceItemAdmin(admin.ModelAdmin): + @admin.register(Item) + class ItemAdmin(admin.ModelAdmin): list_display = ["description", "quantity", "unit_price", "total_price"] - @admin.register(InvoiceColumn) - class InvoiceColumnAdmin(admin.ModelAdmin): + @admin.register(Column) + class ColumnAdmin(admin.ModelAdmin): list_display = ["column_name", "priority", "value"] diff --git a/poetry.lock b/poetry.lock index 60674d7..979b1dd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -679,6 +679,20 @@ xls = ["tablib[xls] (==3.5.0)"] xlsx = ["tablib[xlsx] (==3.5.0)"] yaml = ["tablib[yaml] (==3.5.0)"] +[[package]] +name = "django-jsonform" +version = "2.22.0" +description = "A user-friendly JSON editing form for Django admin." +optional = false +python-versions = ">=3.4" +files = [ + {file = "django-jsonform-2.22.0.tar.gz", hash = "sha256:0c9d50fb371938e7262a7fef7c5a60835dd288f872f87b952d5e2ea84c825221"}, + {file = "django_jsonform-2.22.0-py3-none-any.whl", hash = "sha256:c4dd1ba2b0152bd3164aacf326a83c35355c70d12de81908b5ced5f94c8263d6"}, +] + +[package.dependencies] +django = ">=2.0" + [[package]] name = "django-sage-tools" version = "0.2.2" @@ -2108,4 +2122,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "3e5bf366ab301207d2b2cbaf85ad5549248d9f8be07eb25b695c8505a0ee13be" +content-hash = "0aab8579cac4bd5661ca0b8e83c03b15e686f5414ffa39dd3c533b6621c7a870" diff --git a/pyproject.toml b/pyproject.toml index d16f484..c718314 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ pillow = "^10.4.0" django-sage-tools = "^0.2.2" django-import-export = "^4.1.1" bandit = { extras = [ "toml" ], version = "^1.7.9" } +django-jsonform = "^2.22.0" [tool.poetry.group.dev.dependencies] ruff = "^0.6.1" diff --git a/requirements/poetry.md b/requirements/poetry.md deleted file mode 100644 index 65b8eb4..0000000 --- a/requirements/poetry.md +++ /dev/null @@ -1,30 +0,0 @@ -To export a `requirements-dev.txt` file containing both the main dependencies and the development dependencies from your `pyproject.toml` using Poetry, you can use the `--with` option to include the development dependencies. - -Here is the command you can use: - -```bash -poetry export -f requirements.txt --output requirements-dev.txt --with dev -``` - -Explanation: -- `-f requirements.txt`: Specifies the format of the output file. -- `--output requirements-dev.txt`: Specifies the name of the output file. -- `--with dev`: Includes the dependencies from the `dev` group. - -This command will generate a `requirements-dev.txt` file that includes both the main dependencies and the development dependencies. - -If you want to export the main dependencies to `requirements.txt` and the combined dependencies (main + dev) to `requirements-dev.txt`, you can use the following commands: - -1. Export main dependencies to `requirements.txt`: - - ```bash - poetry export -f requirements.txt --output requirements.txt - ``` - -2. Export main + dev dependencies to `requirements-dev.txt`: - - ```bash - poetry export -f requirements.txt --output requirements-dev.txt --with dev - ``` - -This way, you'll have two separate files: `requirements.txt` for the main dependencies and `requirements-dev.txt` for both the main and development dependencies. diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 09ef99a..2e8a770 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,27 +1,361 @@ -asgiref==3.8.1 ; python_version >= "3.9" and python_version < "4.0" -bandit[toml]==1.7.9 ; python_version >= "3.9" and python_version < "4.0" -cffi==1.17.0 ; python_version >= "3.9" and python_version < "4.0" and platform_python_implementation != "PyPy" -colorama==0.4.6 ; python_version >= "3.9" and python_version < "4.0" and platform_system == "Windows" -cryptography==43.0.0 ; python_version >= "3.9" and python_version < "4.0" -diff-match-patch==20230430 ; python_version >= "3.9" and python_version < "4.0" -django-import-export==4.1.1 ; python_version >= "3.9" and python_version < "4.0" -django-sage-tools==0.2.2 ; python_version >= "3.9" and python_version < "4.0" -django==4.2.15 ; python_version >= "3.9" and python_version < "3.10" -django==5.1 ; python_version >= "3.10" and python_version < "4.0" -jinja2==3.1.4 ; python_version >= "3.9" and python_version < "4.0" -markdown-it-py==3.0.0 ; python_version >= "3.9" and python_version < "4.0" -markupsafe==2.1.5 ; python_version >= "3.9" and python_version < "4.0" -mdurl==0.1.2 ; python_version >= "3.9" and python_version < "4.0" -mimesis==11.1.0 ; python_version >= "3.9" and python_version < "4.0" -pbr==6.1.0 ; python_version >= "3.9" and python_version < "4.0" -pillow==10.4.0 ; python_version >= "3.9" and python_version < "4.0" -pycparser==2.22 ; python_version >= "3.9" and python_version < "4.0" and platform_python_implementation != "PyPy" -pygments==2.18.0 ; python_version >= "3.9" and python_version < "4.0" -pyyaml==6.0.2 ; python_version >= "3.9" and python_version < "4.0" -rich==13.8.0 ; python_version >= "3.9" and python_version < "4.0" -sqlparse==0.5.1 ; python_version >= "3.9" and python_version < "4.0" -stevedore==5.3.0 ; python_version >= "3.9" and python_version < "4.0" -tablib==3.5.0 ; python_version >= "3.9" and python_version < "4.0" -tomli==2.0.1 ; python_version < "3.11" and python_version >= "3.9" -typing-extensions==4.12.2 ; python_version >= "3.9" and python_version < "3.11" -tzdata==2024.1 ; python_version >= "3.9" and python_version < "4.0" and sys_platform == "win32" +asgiref==3.8.1 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \ + --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590 +bandit[toml]==1.7.9 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:52077cb339000f337fb25f7e045995c4ad01511e716e5daac37014b9752de8ec \ + --hash=sha256:7c395a436743018f7be0a4cbb0a4ea9b902b6d87264ddecf8cfdc73b4f78ff61 +cffi==1.17.0 ; python_version >= "3.9" and python_version < "4.0" and platform_python_implementation != "PyPy" \ + --hash=sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f \ + --hash=sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab \ + --hash=sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499 \ + --hash=sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058 \ + --hash=sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693 \ + --hash=sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb \ + --hash=sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377 \ + --hash=sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885 \ + --hash=sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2 \ + --hash=sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401 \ + --hash=sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4 \ + --hash=sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b \ + --hash=sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59 \ + --hash=sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f \ + --hash=sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c \ + --hash=sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555 \ + --hash=sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa \ + --hash=sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424 \ + --hash=sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb \ + --hash=sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2 \ + --hash=sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8 \ + --hash=sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e \ + --hash=sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9 \ + --hash=sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82 \ + --hash=sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828 \ + --hash=sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759 \ + --hash=sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc \ + --hash=sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118 \ + --hash=sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf \ + --hash=sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932 \ + --hash=sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a \ + --hash=sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29 \ + --hash=sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206 \ + --hash=sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2 \ + --hash=sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c \ + --hash=sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c \ + --hash=sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0 \ + --hash=sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a \ + --hash=sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195 \ + --hash=sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6 \ + --hash=sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9 \ + --hash=sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc \ + --hash=sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb \ + --hash=sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0 \ + --hash=sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7 \ + --hash=sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb \ + --hash=sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a \ + --hash=sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492 \ + --hash=sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720 \ + --hash=sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42 \ + --hash=sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7 \ + --hash=sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d \ + --hash=sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d \ + --hash=sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb \ + --hash=sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4 \ + --hash=sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2 \ + --hash=sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b \ + --hash=sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8 \ + --hash=sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e \ + --hash=sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204 \ + --hash=sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3 \ + --hash=sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150 \ + --hash=sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4 \ + --hash=sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76 \ + --hash=sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e \ + --hash=sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb \ + --hash=sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91 +colorama==0.4.6 ; python_version >= "3.9" and python_version < "4.0" and platform_system == "Windows" \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +cryptography==43.0.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709 \ + --hash=sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069 \ + --hash=sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2 \ + --hash=sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b \ + --hash=sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e \ + --hash=sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70 \ + --hash=sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778 \ + --hash=sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22 \ + --hash=sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895 \ + --hash=sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf \ + --hash=sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431 \ + --hash=sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f \ + --hash=sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947 \ + --hash=sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74 \ + --hash=sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc \ + --hash=sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66 \ + --hash=sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66 \ + --hash=sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf \ + --hash=sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f \ + --hash=sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5 \ + --hash=sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e \ + --hash=sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f \ + --hash=sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55 \ + --hash=sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1 \ + --hash=sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47 \ + --hash=sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5 \ + --hash=sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0 +diff-match-patch==20230430 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:953019cdb9c9d2c9e47b5b12bcff3cf4746fc4598eb406076fa1fc27e6a1f15c \ + --hash=sha256:dce43505fb7b1b317de7195579388df0746d90db07015ed47a85e5e44930ef93 +django-import-export==4.1.1 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:16ecc5a9f0df46bde6eb278a3e65ebda0ee1db55656f36440e9fb83f40ab85a3 \ + --hash=sha256:730ae2443a02b1ba27d8dba078a27ae9123adfcabb78161b4f130843607b3df9 +django-jsonform==2.22.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:0c9d50fb371938e7262a7fef7c5a60835dd288f872f87b952d5e2ea84c825221 \ + --hash=sha256:c4dd1ba2b0152bd3164aacf326a83c35355c70d12de81908b5ced5f94c8263d6 +django-sage-tools==0.2.2 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:9244160056dd3907ac85e7888665ed7eed93b0fc2780e444e2b29eee0024372a \ + --hash=sha256:b8b87e1c950b16a73ae4797ee0ac0383a34cedb4fb432143c4913054007aeb14 +django==4.2.15 ; python_version >= "3.9" and python_version < "3.10" \ + --hash=sha256:61ee4a130efb8c451ef3467c67ca99fdce400fedd768634efc86a68c18d80d30 \ + --hash=sha256:c77f926b81129493961e19c0e02188f8d07c112a1162df69bfab178ae447f94a +django==5.1 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:848a5980e8efb76eea70872fb0e4bc5e371619c70fffbe48e3e1b50b2c09455d \ + --hash=sha256:d3b811bf5371a26def053d7ee42a9df1267ef7622323fe70a601936725aa4557 +jinja2==3.1.4 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369 \ + --hash=sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d +markdown-it-py==3.0.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1 \ + --hash=sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb +markupsafe==2.1.5 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf \ + --hash=sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff \ + --hash=sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f \ + --hash=sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3 \ + --hash=sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532 \ + --hash=sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f \ + --hash=sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617 \ + --hash=sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df \ + --hash=sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4 \ + --hash=sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906 \ + --hash=sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f \ + --hash=sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4 \ + --hash=sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8 \ + --hash=sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371 \ + --hash=sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2 \ + --hash=sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465 \ + --hash=sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52 \ + --hash=sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6 \ + --hash=sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169 \ + --hash=sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad \ + --hash=sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2 \ + --hash=sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0 \ + --hash=sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029 \ + --hash=sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f \ + --hash=sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a \ + --hash=sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced \ + --hash=sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5 \ + --hash=sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c \ + --hash=sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf \ + --hash=sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9 \ + --hash=sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb \ + --hash=sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad \ + --hash=sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3 \ + --hash=sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1 \ + --hash=sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46 \ + --hash=sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc \ + --hash=sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a \ + --hash=sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee \ + --hash=sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900 \ + --hash=sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5 \ + --hash=sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea \ + --hash=sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f \ + --hash=sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5 \ + --hash=sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e \ + --hash=sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a \ + --hash=sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f \ + --hash=sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50 \ + --hash=sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a \ + --hash=sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b \ + --hash=sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4 \ + --hash=sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff \ + --hash=sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2 \ + --hash=sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46 \ + --hash=sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b \ + --hash=sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf \ + --hash=sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5 \ + --hash=sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5 \ + --hash=sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab \ + --hash=sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd \ + --hash=sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68 +mdurl==0.1.2 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba +mimesis==11.1.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:574715564c937cd40eba23a0d184febbe04e538d5d120bfa5b951775200f3084 \ + --hash=sha256:5f3839751190f6eef7f453dfafb8f2f38dbdcda11bb3ad742589c216c24985f1 +pbr==6.1.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:788183e382e3d1d7707db08978239965e8b9e4e5ed42669bf4758186734d5f24 \ + --hash=sha256:a776ae228892d8013649c0aeccbb3d5f99ee15e005a4cbb7e61d55a067b28a2a +pillow==10.4.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885 \ + --hash=sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea \ + --hash=sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df \ + --hash=sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5 \ + --hash=sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c \ + --hash=sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d \ + --hash=sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd \ + --hash=sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06 \ + --hash=sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908 \ + --hash=sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a \ + --hash=sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be \ + --hash=sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0 \ + --hash=sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b \ + --hash=sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80 \ + --hash=sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a \ + --hash=sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e \ + --hash=sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9 \ + --hash=sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696 \ + --hash=sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b \ + --hash=sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309 \ + --hash=sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e \ + --hash=sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab \ + --hash=sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d \ + --hash=sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060 \ + --hash=sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d \ + --hash=sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d \ + --hash=sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4 \ + --hash=sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3 \ + --hash=sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6 \ + --hash=sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb \ + --hash=sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94 \ + --hash=sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b \ + --hash=sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496 \ + --hash=sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0 \ + --hash=sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319 \ + --hash=sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b \ + --hash=sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856 \ + --hash=sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef \ + --hash=sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680 \ + --hash=sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b \ + --hash=sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42 \ + --hash=sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e \ + --hash=sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597 \ + --hash=sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a \ + --hash=sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8 \ + --hash=sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3 \ + --hash=sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736 \ + --hash=sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da \ + --hash=sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126 \ + --hash=sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd \ + --hash=sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5 \ + --hash=sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b \ + --hash=sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026 \ + --hash=sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b \ + --hash=sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc \ + --hash=sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46 \ + --hash=sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2 \ + --hash=sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c \ + --hash=sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe \ + --hash=sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984 \ + --hash=sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a \ + --hash=sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70 \ + --hash=sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca \ + --hash=sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b \ + --hash=sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91 \ + --hash=sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3 \ + --hash=sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84 \ + --hash=sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1 \ + --hash=sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5 \ + --hash=sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be \ + --hash=sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f \ + --hash=sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc \ + --hash=sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9 \ + --hash=sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e \ + --hash=sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141 \ + --hash=sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef \ + --hash=sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22 \ + --hash=sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27 \ + --hash=sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e \ + --hash=sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1 +pycparser==2.22 ; python_version >= "3.9" and python_version < "4.0" and platform_python_implementation != "PyPy" \ + --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ + --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc +pygments==2.18.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199 \ + --hash=sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a +pyyaml==6.0.2 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ + --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ + --hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 \ + --hash=sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e \ + --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ + --hash=sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5 \ + --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ + --hash=sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee \ + --hash=sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5 \ + --hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 \ + --hash=sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a \ + --hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf \ + --hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 \ + --hash=sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8 \ + --hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85 \ + --hash=sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19 \ + --hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \ + --hash=sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a \ + --hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \ + --hash=sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317 \ + --hash=sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c \ + --hash=sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631 \ + --hash=sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d \ + --hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \ + --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ + --hash=sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e \ + --hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b \ + --hash=sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8 \ + --hash=sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476 \ + --hash=sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706 \ + --hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \ + --hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 \ + --hash=sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b \ + --hash=sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083 \ + --hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 \ + --hash=sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425 \ + --hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e \ + --hash=sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f \ + --hash=sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725 \ + --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ + --hash=sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab \ + --hash=sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774 \ + --hash=sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725 \ + --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ + --hash=sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5 \ + --hash=sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d \ + --hash=sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290 \ + --hash=sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44 \ + --hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed \ + --hash=sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4 \ + --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ + --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ + --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 +rich==13.8.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc \ + --hash=sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4 +sqlparse==0.5.1 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4 \ + --hash=sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e +stevedore==5.3.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:1efd34ca08f474dad08d9b19e934a22c68bb6fe416926479ba29e5013bcc8f78 \ + --hash=sha256:9a64265f4060312828151c204efbe9b7a9852a0d9228756344dbc7e4023e375a +tablib==3.5.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:9821caa9eca6062ff7299fa645e737aecff982e6b2b42046928a6413c8dabfd9 \ + --hash=sha256:f6661dfc45e1d4f51fa8a6239f9c8349380859a5bfaa73280645f046d6c96e33 +tomli==2.0.1 ; python_version < "3.11" and python_version >= "3.9" \ + --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \ + --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f +typing-extensions==4.12.2 ; python_version >= "3.9" and python_version < "3.11" \ + --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ + --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 +tzdata==2024.1 ; python_version >= "3.9" and python_version < "4.0" and sys_platform == "win32" \ + --hash=sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd \ + --hash=sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252 diff --git a/sage_invoice/admin/__init__.py b/sage_invoice/admin/__init__.py index 44d5616..49f9fef 100644 --- a/sage_invoice/admin/__init__.py +++ b/sage_invoice/admin/__init__.py @@ -1,4 +1,4 @@ -from .category import InvoiceCategoryAdmin +from .category import CategoryAdmin from .invoice import InvoiceAdmin -__all__ = ["InvoiceAdmin", "InvoiceCategoryAdmin"] +__all__ = ["InvoiceAdmin", "CategoryAdmin"] diff --git a/sage_invoice/admin/actions/__init__.py b/sage_invoice/admin/actions/__init__.py index 43fd842..dea1852 100644 --- a/sage_invoice/admin/actions/__init__.py +++ b/sage_invoice/admin/actions/__init__.py @@ -1,3 +1,3 @@ -from .show import show_invoice +from .download_pdf import export_pdf -__all__ = ["show_invoice"] +__all__ = ["export_pdf"] diff --git a/sage_invoice/admin/actions/download_pdf.py b/sage_invoice/admin/actions/download_pdf.py new file mode 100644 index 0000000..841b6c9 --- /dev/null +++ b/sage_invoice/admin/actions/download_pdf.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from django.shortcuts import redirect +from django.urls import reverse + + +@admin.action(description="Download selected invoice as PDF") +def export_pdf(modeladmin, request, queryset): + invoice_ids = queryset.values_list("id", flat=True) + invoice_ids_str = ",".join(map(str, invoice_ids)) + url = f"{reverse('download_invoices')}?invoice_ids={invoice_ids_str}" + return redirect(url) diff --git a/sage_invoice/admin/actions/show.py b/sage_invoice/admin/actions/show.py deleted file mode 100644 index 7b302ba..0000000 --- a/sage_invoice/admin/actions/show.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.contrib import admin -from django.shortcuts import redirect -from django.urls import reverse - - -@admin.action(description="Show selected invoice") -def show_invoice(modeladmin, request, queryset): - invoice = queryset.first() - if invoice: - url = reverse("invoice_detail", kwargs={"invoice_slug": invoice.slug}) - return redirect(url) diff --git a/sage_invoice/admin/category.py b/sage_invoice/admin/category.py index 9b4e1a6..9a436e7 100644 --- a/sage_invoice/admin/category.py +++ b/sage_invoice/admin/category.py @@ -1,11 +1,11 @@ from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from sage_invoice.models import InvoiceCategory +from sage_invoice.models import Category -@admin.register(InvoiceCategory) -class InvoiceCategoryAdmin(admin.ModelAdmin): +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): admin_priority = 2 list_display = ("title", "description") search_fields = ("title", "description") diff --git a/sage_invoice/admin/invoice.py b/sage_invoice/admin/invoice.py index 69c278f..faa9fc4 100644 --- a/sage_invoice/admin/invoice.py +++ b/sage_invoice/admin/invoice.py @@ -2,27 +2,33 @@ from django.utils.translation import gettext_lazy as _ from import_export.admin import ImportExportModelAdmin -from sage_invoice.admin.actions import show_invoice -from sage_invoice.models import Expense, Invoice, InvoiceColumn, InvoiceItem +from sage_invoice.admin.actions import export_pdf +from sage_invoice.models import Column, Expense, Invoice, Item from sage_invoice.resource import InvoiceResource -class InvoiceItemInline(admin.TabularInline): - model = InvoiceItem +class ItemInline(admin.TabularInline): + model = Item extra = 0 min_num = 1 readonly_fields = ("total_price",) -class InvoiceColumnInline(admin.TabularInline): - model = InvoiceColumn +class ColumnInline(admin.TabularInline): + model = Column extra = 1 class ExpenseInline(admin.TabularInline): model = Expense extra = 1 - readonly_fields = ("subtotal", "tax_amount", "discount_amount", "total_amount") + readonly_fields = ( + "subtotal", + "tax_amount", + "discount_amount", + "total_amount", + "concession_amount", + ) @admin.register(Invoice) @@ -31,12 +37,12 @@ class InvoiceAdmin(ImportExportModelAdmin, admin.ModelAdmin): admin_priority = 1 list_display = ("title", "invoice_date", "customer_name", "status") search_fields = ("customer_name", "status", "customer_email") - autocomplete_fields = ("category",) save_on_top = True list_filter = ("status", "invoice_date", "category") ordering = ("-invoice_date",) + autocomplete_fields = ("category",) readonly_fields = ("slug",) - actions = [show_invoice] + actions = [export_pdf] class Media: js = ("assets/js/invoice_admin.js",) @@ -53,19 +59,21 @@ def get_fieldsets(self, request, obj=None): "tracking_code", "due_date", "customer_name", - "customer_email", + "contacts", "category", "receipt", ), "description": _( - "Basic details of the invoice including title, date, and customer information." + """ + Basic details of the invoice including title, date, + and customer information.""" ), }, ), ( - _("Status & Notes"), + _("Status & Currency"), { - "fields": ("status", "notes"), + "fields": ("status", "notes", "currency"), "description": _( "Current status of the invoice and any additional notes." ), @@ -91,19 +99,19 @@ def get_fieldsets(self, request, obj=None): return fieldsets - inlines = [InvoiceItemInline, InvoiceColumnInline, ExpenseInline] + inlines = [ItemInline, ColumnInline, ExpenseInline] def get_inline_instances(self, request, obj=None): inlines = [] if obj and obj.pk: inlines = [ - InvoiceItemInline(self.model, self.admin_site), - InvoiceColumnInline(self.model, self.admin_site), + ItemInline(self.model, self.admin_site), + ColumnInline(self.model, self.admin_site), ExpenseInline(self.model, self.admin_site), ] else: inlines = [ - InvoiceItemInline(self.model, self.admin_site), + ItemInline(self.model, self.admin_site), ExpenseInline(self.model, self.admin_site), ] return inlines diff --git a/sage_invoice/helpers/choice.py b/sage_invoice/helpers/choice.py index b07b69e..e9bb0a9 100644 --- a/sage_invoice/helpers/choice.py +++ b/sage_invoice/helpers/choice.py @@ -4,3 +4,32 @@ class InvoiceStatus(models.TextChoices): PAID = ("paid", "PAID") UNPAID = ("unpaid", "UNPAID") + + +class Currency(models.TextChoices): + USD = ("USD", "US Dollar") + EUR = ("EUR", "Euro") + GBP = ("GBP", "British Pound") + JPY = ("JPY", "Japanese Yen") + AUD = ("AUD", "Australian Dollar") + CAD = ("CAD", "Canadian Dollar") + CHF = ("CHF", "Swiss Franc") + CNY = ("CNY", "Chinese Yuan") + INR = ("INR", "Indian Rupee") + RUB = ("RUB", "Russian Ruble") + AED = ("AED", "UAE Dirham") + SAR = ("SAR", "Saudi Riyal") + TRY = ("TRY", "Turkish Lira") + BRL = ("BRL", "Brazilian Real") + ZAR = ("ZAR", "South African Rand") + NZD = ("NZD", "New Zealand Dollar") + KRW = ("KRW", "South Korean Won") + SGD = ("SGD", "Singapore Dollar") + MXN = ("MXN", "Mexican Peso") + IRR = ("IRR", "Iranian Rial") + TOMAN = ("TOMAN", "Iranian Toman") + QAR = ("QAR", "Qatari Riyal") + KWD = ("KWD", "Kuwaiti Dinar") + BHD = ("BHD", "Bahraini Dinar") + OMR = ("OMR", "Omani Rial") + EGP = ("EGP", "Egyptian Pound") diff --git a/sage_invoice/helpers/funcs.py b/sage_invoice/helpers/funcs.py index d761c1e..fbe2e69 100644 --- a/sage_invoice/helpers/funcs.py +++ b/sage_invoice/helpers/funcs.py @@ -1,4 +1,6 @@ import os +import secrets +from datetime import datetime from django.conf import settings @@ -28,3 +30,16 @@ def get_template_choices(is_receipt=False): ] return choices or [("", "No Templates Available")] + + +def generate_tracking_code(user_input: str, creation_date: datetime) -> str: + """Generate a unique tracking code based on user input and the creation + date. + + Returns: + str: A unique tracking code. + """ + date_str = creation_date.strftime("%Y%m%d") + random_number = secrets.randbelow(8000) + 1000 + tracking_code = f"{user_input}-{date_str}-{random_number}" + return tracking_code diff --git a/sage_invoice/models/__init__.py b/sage_invoice/models/__init__.py index 2b2e7d6..cdeb9a4 100644 --- a/sage_invoice/models/__init__.py +++ b/sage_invoice/models/__init__.py @@ -1,7 +1,7 @@ +from .category import Category +from .column import Column +from .expense import Expense from .invoice import Invoice -from .invoice_category import InvoiceCategory -from .invoice_column import InvoiceColumn -from .invoice_item import InvoiceItem -from .invoice_total import Expense +from .item import Item -__all__ = ["Invoice", "InvoiceColumn", "InvoiceItem", "Expense", "InvoiceCategory"] +__all__ = ["Invoice", "Column", "Item", "Expense", "Category"] diff --git a/sage_invoice/models/invoice_category.py b/sage_invoice/models/category.py similarity index 58% rename from sage_invoice/models/invoice_category.py rename to sage_invoice/models/category.py index b2ffdfb..1523260 100644 --- a/sage_invoice/models/invoice_category.py +++ b/sage_invoice/models/category.py @@ -3,23 +3,20 @@ from sage_tools.mixins.models import TitleSlugMixin -class InvoiceCategory(TitleSlugMixin): +class Category(TitleSlugMixin): description = models.CharField( - verbose_name=_("Description"), max_length=255, + verbose_name=_("Description"), null=True, blank=True, help_text=_("Description of the Category."), - db_comment="The description of the category", + db_comment="Description of the Category", ) def __str__(self): return f"{self.title}" - def __repr__(self): - return f"{self.title}" - class Meta: - verbose_name = _("Invoice Category") - verbose_name_plural = _("Invoice Categories ") - db_table = "sage_invoice_category" + verbose_name = _("Category") + verbose_name_plural = _("Categories ") + db_table = "sage_invoice_cat" diff --git a/sage_invoice/models/invoice_column.py b/sage_invoice/models/column.py similarity index 87% rename from sage_invoice/models/invoice_column.py rename to sage_invoice/models/column.py index 933e730..6a3bb3d 100644 --- a/sage_invoice/models/invoice_column.py +++ b/sage_invoice/models/column.py @@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _ -class InvoiceColumn(models.Model): +class Column(models.Model): priority = models.PositiveIntegerField( verbose_name=_("Priority"), help_text=_("The priority associated with each custom column."), @@ -30,7 +30,7 @@ class InvoiceColumn(models.Model): db_comment="invoice of the custom column", ) item = models.ForeignKey( - "InvoiceItem", + "Item", on_delete=models.CASCADE, related_name="columns", verbose_name=_("Item"), @@ -42,10 +42,10 @@ def __str__(self): return f"{self.column_name}" def __repr__(self) -> str: - return f"Invoice Column> {self.column_name}" + return f"Column> {self.column_name}" class Meta: - verbose_name = _("Invoice Column") - verbose_name_plural = _("Invoice Columns") + verbose_name = _("Column") + verbose_name_plural = _("Columns") db_table = "sage_invoice_columns" ordering = ["priority"] diff --git a/sage_invoice/models/invoice_total.py b/sage_invoice/models/expense.py similarity index 79% rename from sage_invoice/models/invoice_total.py rename to sage_invoice/models/expense.py index 42d0c85..d23c63b 100644 --- a/sage_invoice/models/invoice_total.py +++ b/sage_invoice/models/expense.py @@ -29,6 +29,14 @@ class Expense(models.Model): help_text=_("The discount percentage applied to the invoice."), db_comment="The percentage of discount applied to the invoice", ) + concession_percentage = models.DecimalField( + verbose_name=_("Concession Percentage"), + max_digits=5, + decimal_places=2, + default=Decimal("0.00"), + help_text=_("The concession percentage applied to the invoice."), + db_comment="The percentage of concession applied to the invoice", + ) tax_amount = models.DecimalField( verbose_name=_("Tax Amount"), max_digits=10, @@ -45,6 +53,14 @@ class Expense(models.Model): help_text=_("The calculated discount amount."), db_comment="The total discount amount calculated based on the subtotal and discount percentage", ) + concession_amount = models.DecimalField( + verbose_name=_("Concession Amount"), + max_digits=10, + decimal_places=2, + default=Decimal("0.00"), + help_text=_("The calculated concession amount."), + db_comment="The total concession amount calculated based on the subtotal and discount percentage", + ) total_amount = models.DecimalField( verbose_name=_("Total Amount"), max_digits=10, diff --git a/sage_invoice/models/invoice.py b/sage_invoice/models/invoice.py index 3f0c974..75f334d 100644 --- a/sage_invoice/models/invoice.py +++ b/sage_invoice/models/invoice.py @@ -1,11 +1,12 @@ from django.core.exceptions import ValidationError from django.db import models -from django.utils.translation import gettext_lazy as _ from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django_jsonform.models.fields import JSONField from sage_tools.mixins.models import TitleSlugMixin -from sage_invoice.helpers.choice import InvoiceStatus -from sage_invoice.helpers.funcs import get_template_choices +from sage_invoice.helpers.choice import Currency, InvoiceStatus +from sage_invoice.helpers.funcs import generate_tracking_code, get_template_choices class Invoice(TitleSlugMixin): @@ -23,13 +24,48 @@ class Invoice(TitleSlugMixin): tracking_code = models.CharField( max_length=255, verbose_name=_("Tracking Code"), - help_text=_("The tracking code of the invoice."), + help_text=_( + "Enter the first 3-4 characters of the tracking code. The full code will be auto-generated as + + ." + ), db_comment="Tracking code created", ) - customer_email = models.EmailField( - verbose_name=_("Customer Email"), - help_text=_("The email of the customer."), - db_comment="Customer email created", + contacts = JSONField( + verbose_name="Customer Contacts", + blank=True, + null=True, + schema={ + "type": "object", + "properties": { + "Contact Info": { + "oneOf": [ + { + "type": "object", + "title": "Phone", + "properties": { + "phone": { + "type": "string", + "pattern": "^[0-9]+$", + "title": "Phone", + "placeholder": "1234567890", + } + }, + }, + { + "type": "object", + "title": "Email", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email", + "placeholder": "you@example.com", + } + }, + }, + ] + } + }, + }, ) status = models.CharField( max_length=50, @@ -44,23 +80,52 @@ class Invoice(TitleSlugMixin): help_text=_("Is this a receipt or an invoice"), db_comment="Check if this invoice is a receipt", ) - notes = models.TextField( + notes = JSONField( verbose_name=_("Notes"), blank=True, null=True, - help_text=_("Additional notes regarding the invoice."), - db_comment="Additional notes regarding the invoice", + help_text=( + """ + You can add any number of custom fields dynamically, + such as'Terms & Conditions', 'Technology Tips', etc. + """ + ), + db_comment=("This field stores additional dynamic content in JSON format. "), + schema={ + "type": "array", + "title": "Additional Fields", + "items": { + "type": "object", + "title": "Field", + "properties": { + "label": {"type": "string", "title": "Field Name"}, + "content": { + "type": "string", + "title": "Field Content", + "widget": "textarea", + }, + }, + }, + }, ) category = models.ForeignKey( - "InvoiceCategory", + "Category", on_delete=models.CASCADE, related_name="category", - null=False, - blank=False, + null=True, + blank=True, verbose_name=_("Category"), help_text=_("The category associated with this invoice."), db_comment="Category associated with this invoice", ) + currency = models.CharField( + max_length=10, + verbose_name="Currency", + choices=Currency.choices, + default=Currency.USD, + help_text=_("Currency of unit price"), + db_comment="Which currency is this item ", + ) due_date = models.DateField( verbose_name=_("Due Date"), help_text=_("The date by which the invoice should be paid."), @@ -101,9 +166,13 @@ class Invoice(TitleSlugMixin): def clean(self): if self.due_date < self.invoice_date: raise ValidationError(_("Due Date must be later than Invoice Date.")) + if len(self.tracking_code) <= 10: + self.tracking_code = generate_tracking_code( + self.tracking_code, self.invoice_date + ) def get_absolute_url(self): - return reverse('invoice_detail', kwargs={'slug': self.slug}) + return reverse("invoice_detail", kwargs={"slug": self.slug}) class Meta: verbose_name = _("Invoice") diff --git a/sage_invoice/models/invoice_item.py b/sage_invoice/models/item.py similarity index 78% rename from sage_invoice/models/invoice_item.py rename to sage_invoice/models/item.py index d12b6f3..bf53624 100644 --- a/sage_invoice/models/invoice_item.py +++ b/sage_invoice/models/item.py @@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _ -class InvoiceItem(models.Model): +class Item(models.Model): description = models.CharField( max_length=255, verbose_name=_("Description"), @@ -15,6 +15,14 @@ class InvoiceItem(models.Model): help_text=_("The quantity of the item."), db_comment="The quantity of the invoice item", ) + measurement = models.CharField( + max_length=255, + verbose_name=_("measurement"), + help_text=_("measurement of the quantity."), + db_comment="measurement gavin more info about the item quantity", + null=True, + blank=True, + ) unit_price = models.DecimalField( max_digits=10, decimal_places=2, @@ -48,9 +56,9 @@ def __str__(self): return f"{self.description} - {self.quantity} x {self.unit_price}" def __repr__(self): - return f"Invoice item> {self.description} - {self.quantity} x {self.unit_price}" + return f"Items> {self.description} - {self.quantity} x {self.unit_price}" class Meta: - verbose_name = _("Invoice Item") - verbose_name_plural = _("Invoice Items") + verbose_name = _("Item") + verbose_name_plural = _("Items") db_table = "sage_invoice_items" diff --git a/sage_invoice/resource.py b/sage_invoice/resource.py index 594c5bd..fd2b42d 100644 --- a/sage_invoice/resource.py +++ b/sage_invoice/resource.py @@ -1,24 +1,38 @@ +import json +from decimal import Decimal + +from django.core.exceptions import ValidationError from import_export import fields, resources -from import_export.widgets import BooleanWidget, DateWidget, ForeignKeyWidget +from import_export.widgets import BooleanWidget, DateWidget, ForeignKeyWidget, Widget + +from sage_invoice.models import Category, Column, Expense, Invoice, Item + + +class JSONFieldWidget(Widget): + def clean(self, value, row=None, *args, **kwargs): + if not value: + return None + try: + return json.loads(value) + except json.JSONDecodeError as err: + raise ValidationError(f"Invalid JSON format in field: {value}") from err -from sage_invoice.models import ( - Expense, - Invoice, - InvoiceCategory, - InvoiceColumn, - InvoiceItem, -) + def render(self, value, obj=None): + if value is None: + return "" + return json.dumps(value) -class InvoiceItemResource(resources.ModelResource): +# ItemResource handles Invoice Items +class ItemResource(resources.ModelResource): invoice = fields.Field( - column_name="invoice", + column_name="invoice_id", attribute="invoice", - widget=ForeignKeyWidget(Invoice, "id"), + widget=ForeignKeyWidget(Invoice, "id"), # Use 'id' for foreign key lookup ) class Meta: - model = InvoiceItem + model = Item fields = ( "id", "invoice", @@ -28,30 +42,32 @@ class Meta: "total_price", ) import_id_fields = ["id"] - skip_unchanged = True - report_skipped = True -class InvoiceColumnResource(resources.ModelResource): +# ColumnResource handles custom columns for invoices +class ColumnResource(resources.ModelResource): invoice = fields.Field( - column_name="invoice", + column_name="invoice_id", attribute="invoice", widget=ForeignKeyWidget(Invoice, "id"), ) + item = fields.Field( + column_name="item_id", + attribute="item", + widget=ForeignKeyWidget(Item, "id"), + ) class Meta: - model = InvoiceColumn - fields = ("id", "invoice", "header", "value") + model = Column + fields = ("id", "invoice", "item", "column_name", "value", "priority") import_id_fields = ["id"] - skip_unchanged = True - report_skipped = True class ExpenseResource(resources.ModelResource): invoice = fields.Field( - column_name="invoice", + column_name="invoice_id", attribute="invoice", - widget=ForeignKeyWidget(Invoice, "id"), + widget=ForeignKeyWidget(Invoice, "id"), # Use 'id' for foreign key lookup ) class Meta: @@ -67,15 +83,32 @@ class Meta: "total_amount", ) import_id_fields = ["id"] - skip_unchanged = True - report_skipped = True +# CategoryResource handles Invoice Categories +class CategoryResource(resources.ModelResource): + class Meta: + model = Category + fields = ( + "id", + "title", + "description", + ) + import_id_fields = ["id"] + + +# Main InvoiceResource to handle invoice importing/exporting class InvoiceResource(resources.ModelResource): - category = fields.Field( - column_name="category", - attribute="category", - widget=ForeignKeyWidget(InvoiceCategory, "name"), + notes = fields.Field( + column_name="notes", + attribute="notes", + widget=JSONFieldWidget(), + ) + + contacts = fields.Field( + column_name="contacts", + attribute="contacts", + widget=JSONFieldWidget(), ) invoice_date = fields.Field( @@ -83,7 +116,6 @@ class InvoiceResource(resources.ModelResource): attribute="invoice_date", widget=DateWidget(format="%Y-%m-%d"), ) - due_date = fields.Field( column_name="due_date", attribute="due_date", @@ -91,13 +123,15 @@ class InvoiceResource(resources.ModelResource): ) receipt = fields.Field( - column_name="receipt", attribute="receipt", widget=BooleanWidget() + column_name="receipt", + attribute="receipt", + widget=BooleanWidget(), ) - # Define custom fields for related objects items = fields.Field() columns = fields.Field() totals = fields.Field() + category = fields.Field() class Meta: model = Invoice @@ -109,7 +143,7 @@ class Meta: "customer_email", "status", "receipt", - "category", + "contacts", "due_date", "tracking_code", "notes", @@ -117,60 +151,145 @@ class Meta: "signature", "stamp", "template_choice", - "items", - "columns", - "totals", - ) - export_order = ( - "id", - "title", - "invoice_date", - "customer_name", - "customer_email", - "status", - "receipt", "category", - "due_date", - "tracking_code", - "notes", - "logo", - "signature", - "stamp", - "template_choice", "items", "columns", "totals", ) import_id_fields = ["id"] - skip_unchanged = True - report_skipped = True - def dehydrate_category(self, invoice): - category = invoice.category - if category: - return category.title - return "" + def before_import_row(self, row, **kwargs): + """ + Before importing, handle category, items, columns, and totals exactly like + other fields. + """ + # Initialize the invoice_item_map for each row import + self.invoice_item_map = {} + + if row.get("items"): + self.create_invoice_items(row["items"], row["id"]) + + if row.get("columns"): + self.create_invoice_columns(row["columns"], row["id"]) + if row.get("totals"): + self.create_expenses(row["totals"], row["id"]) + + def after_import_row(self, row, row_result, **kwargs): + """After row import, assign the created or updated category to the invoice.""" + if row.get("category"): + category_data = row["category"].split("|") + title = category_data[0].strip() + description = category_data[1].strip() if len(category_data) > 1 else "" + + # Create or update the Category + category, created = Category.objects.update_or_create( + title=title, + defaults={"description": description}, + ) + + # Now, directly set the category for the current invoice + invoice = Invoice.objects.get(id=row["id"]) + invoice.category = category + invoice.save() + + def create_invoice_items(self, items_data, invoice_id): + items = items_data.split("; ") + for item_data in items: + try: + description, quantity, unit_price, total_price = item_data.split("|") + item, created = Item.objects.update_or_create( + invoice_id=invoice_id, + description=description.strip(), + defaults={ + "quantity": int(quantity.strip()), + "unit_price": Decimal(unit_price.strip()), + "total_price": Decimal(total_price.strip()), + }, + ) + self.invoice_item_map[invoice_id] = item + except ValueError as err: + raise ValidationError(f"Invalid item data:{item_data}") from err + + def create_invoice_columns(self, columns_data, invoice_id): + columns = columns_data.split("; ") + for column_data in columns: + try: + column_name, value, count = column_data.split("|") + item = self.invoice_item_map.get(invoice_id) + if not item: + raise ValidationError( + f"No matching invoice item for invoice {invoice_id}" + ) + Column.objects.update_or_create( + invoice_id=invoice_id, + item=item, + column_name=column_name.strip(), + defaults={"value": value.strip(), "priority": count}, + ) + except ValueError as err: + raise ValidationError(f"Invalid column data:{column_data}") from err + + def create_expenses(self, totals_data, invoice_id): + try: + ( + subtotal, + tax_percentage, + discount_percentage, + concession_percentage, + ) = totals_data.split("|") + tax_amount = Decimal(subtotal) * (Decimal(tax_percentage) / 100) + discount_amount = Decimal(subtotal) * (Decimal(discount_percentage) / 100) + total_amount = (Decimal(subtotal) + tax_amount) - discount_amount + + Expense.objects.update_or_create( + invoice_id=invoice_id, + defaults={ + "subtotal": Decimal(subtotal), + "tax_percentage": Decimal(tax_percentage), + "discount_percentage": Decimal(discount_percentage), + "tax_amount": tax_amount, + "discount_amount": discount_amount, + "total_amount": total_amount, + }, + ) + except ValueError as err: + raise ValidationError(f"Invalid totals data: {totals_data}") from err + + # Export related data for items, columns, totals, and contacts def dehydrate_items(self, invoice): - items = InvoiceItem.objects.filter(invoice=invoice) + items = Item.objects.filter(invoice=invoice) return "; ".join( [ - f"{item.description} (Quantity: {item.quantity}, Unit Price: {item.unit_price}, Total: {item.total_price})" + f"{item.description}|{item.quantity}|{item.unit_price}|{item.total_price}" for item in items ] ) def dehydrate_columns(self, invoice): - columns = InvoiceColumn.objects.filter(invoice=invoice) + columns = Column.objects.filter(invoice=invoice) return "; ".join( - [f"{column.column_name}: {column.value}" for column in columns] + [ + f"{column.column_name}|{column.value}|{column.priority}" + for column in columns + ] ) def dehydrate_totals(self, invoice): totals = Expense.objects.filter(invoice=invoice) return "; ".join( [ - f"Subtotal: {total.subtotal}, Tax: {total.tax_amount} ({total.tax_percentage}%), Discount: {total.discount_amount} ({total.discount_percentage}%), Total: {total.total_amount}" + f"{total.subtotal}|{total.tax_percentage}|{total.discount_percentage}|{total.concession_percentage}" for total in totals ] ) + + def dehydrate_category(self, invoice): + """Export category data as 'title|description' format.""" + if invoice.category: + return f"{invoice.category.title}|{invoice.category.description}" + return "" + + def dehydrate_contacts(self, invoice): + """Export contacts JSON field.""" + return json.dumps(invoice.contacts) diff --git a/sage_invoice/service/invoice_create.py b/sage_invoice/service/invoice_create.py index 5b4fbc8..ff5a25d 100644 --- a/sage_invoice/service/invoice_create.py +++ b/sage_invoice/service/invoice_create.py @@ -1,7 +1,5 @@ import logging import os -import secrets -from datetime import datetime from typing import Any, Dict from django.conf import settings @@ -9,7 +7,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2.exceptions import TemplateNotFound -from sage_invoice.models import InvoiceColumn +from sage_invoice.models import Column from .discovery import JinjaTemplateDiscovery @@ -38,19 +36,6 @@ def __init__(self) -> None: self.template_discovery.models_dir, ) - def generate_tracking_code(self, user_input: str, creation_date: datetime) -> str: - """Generate a unique tracking code based on user input and the creation - date. - - Returns: - str: A unique tracking code. - """ - date_str = creation_date.strftime("%Y%m%d") - random_number = secrets.randbelow(8000) + 1000 - tracking_code = f"{user_input}-{date_str}-{random_number}" - logger.info("Generated tracking code: %s", tracking_code) - return tracking_code - def render_quotation(self, queryset: QuerySet) -> str: """Render the quotation for the given queryset. @@ -90,20 +75,30 @@ def render_contax(self, queryset: QuerySet) -> Dict[str, Any]: Dict[str, Any]: The context data for rendering the quotation. """ logger.info("Preparing context data for quotation") - invoice = queryset.first() + invoice = queryset total = invoice.total items = invoice.items.all() - + email = None + phone = "" item_list = [] custom_columns = set() + additional_fields = invoice.notes if hasattr(invoice, "notes") else [] + contacts = invoice.contacts + + for contact in contacts: + if "@" in contact and not email: + email = contact + elif contact.isdigit() and not phone: + phone = contact for item in items: - custom_data = InvoiceColumn.objects.filter(item=item).order_by("priority") + custom_data = Column.objects.filter(item=item).order_by("priority") custom_fields = {data.column_name: data.value for data in custom_data} custom_columns.update(custom_fields.keys()) item_dict = { "description": item.description, "quantity": item.quantity, + "measurement": item.measurement if item.measurement else "", "unit_price": item.unit_price, "total_price": item.total_price, "custom_data": custom_fields, @@ -112,26 +107,28 @@ def render_contax(self, queryset: QuerySet) -> Dict[str, Any]: context = { "title": invoice.title, - "tracking_code": self.generate_tracking_code( - invoice.tracking_code, invoice.invoice_date - ), + "tracking_code": invoice.tracking_code, "items": item_list, "subtotal": total.subtotal, "tax_percentage": total.tax_percentage, "tax_amount": total.tax_amount, "discount_percentage": total.discount_percentage, "discount_amount": total.discount_amount, + "concession_percentage": total.concession_percentage, + "concession_amount": total.concession_amount, "grand_total": total.total_amount, "invoice_date": invoice.invoice_date, "customer_name": invoice.customer_name, - "customer_email": invoice.customer_email, + "customer_email": email, + "customer_phone": phone, "due_date": invoice.due_date, "status": invoice.status, - "notes": invoice.notes, + "currency": invoice.currency, "logo_url": invoice.logo.url if invoice.logo else None, "sign_url": invoice.signature.url if invoice.signature else None, "stamp_url": invoice.stamp.url if invoice.stamp else None, "custom_columns": custom_columns, + "additional_fields": additional_fields, } logger.info("Context prepared for invoice: %s", invoice.title) diff --git a/sage_invoice/service/total.py b/sage_invoice/service/total.py index e22ed6e..9d7e65d 100644 --- a/sage_invoice/service/total.py +++ b/sage_invoice/service/total.py @@ -16,7 +16,7 @@ def calculate_and_save(self, invoice_total, *args, **kwargs): # Convert tax and discount percentages to Decimal tax_percentage = Decimal(invoice_total.tax_percentage) discount_percentage = Decimal(invoice_total.discount_percentage) - + concession_percentage = Decimal(invoice_total.concession_percentage) # Calculate tax amount invoice_total.tax_amount = invoice_total.subtotal * ( tax_percentage / Decimal(100) @@ -26,12 +26,15 @@ def calculate_and_save(self, invoice_total, *args, **kwargs): invoice_total.discount_amount = invoice_total.subtotal * ( discount_percentage / Decimal(100) ) - + invoice_total.concession_amount = invoice_total.subtotal * ( + concession_percentage / Decimal(100) + ) # Calculate total amount invoice_total.total_amount = ( invoice_total.subtotal + invoice_total.tax_amount - invoice_total.discount_amount + - invoice_total.concession_amount ) # Save the Expense instance using the standard save method diff --git a/sage_invoice/signals.py b/sage_invoice/signals.py index b45760a..54801bf 100644 --- a/sage_invoice/signals.py +++ b/sage_invoice/signals.py @@ -20,15 +20,17 @@ def recalculate_total(): try: transaction.on_commit(recalculate_total) - except IntegrityError as e: - logger.error("Integrity error occurred for invoice %s: %s", instance.title, e) - except OperationalError as e: + except IntegrityError as error: logger.error( - "Operational error in database for invoice %s: %s", instance.title, e + "Integrity error occurred for invoice %s: %s", instance.title, error ) - except ValidationError as e: - logger.error("Validation error for invoice %s: %s", instance.title, e) - except Exception as e: + except OperationalError as error: + logger.error( + "Operational error in database for invoice %s: %s", instance.title, error + ) + except ValidationError as error: + logger.error("Validation error for invoice %s: %s", instance.title, error) + except Exception as error: logger.exception( - "Unexpected error occurred for invoice %s: %s", instance.title, e + "Unexpected error occurred for invoice %s: %s", instance.title, error ) diff --git a/sage_invoice/templates/download_invoices.html b/sage_invoice/templates/download_invoices.html new file mode 100644 index 0000000..fb41ad6 --- /dev/null +++ b/sage_invoice/templates/download_invoices.html @@ -0,0 +1,137 @@ + + +{% load static %} + + + + + Processing Invoice Download... + + + + + + + + + + + + + + +
+ + + + + diff --git a/sage_invoice/templates/quotation1.html b/sage_invoice/templates/quotation1.html index 2800c60..3c68b00 100644 --- a/sage_invoice/templates/quotation1.html +++ b/sage_invoice/templates/quotation1.html @@ -42,7 +42,7 @@ {{ title }}
- Total: {{ grand_total }} + Total: {{ grand_total }} {{ currency }}
@@ -75,8 +75,8 @@

{{ column }} {% endfor %} Quantity - Unit Price (QAR) - Total Price (QAR) + Unit Price + Total Price @@ -87,9 +87,9 @@

{{ item.custom_data|get_item:column }} {% endfor %} - {{ item.quantity }} - {{ item.unit_price }} - {{ item.total_price }} + {{ item.quantity }} {{ item.measurement }} + {{ item.unit_price }} {{ currency }} + {{ item.total_price }} {{ currency }} {% endfor %} @@ -98,27 +98,29 @@

diff --git a/sage_invoice/templates/quotation2.html b/sage_invoice/templates/quotation2.html index aa81fd8..dbc7ed2 100644 --- a/sage_invoice/templates/quotation2.html +++ b/sage_invoice/templates/quotation2.html @@ -71,6 +71,7 @@

{{ title }}

{{ customer_name }}
{{ customer_email }} + {{ customer_phone}}

@@ -98,9 +99,9 @@

{{ title }}

{% for column in custom_columns %} {{ item.custom_data|get_item:column }} {% endfor %} - {{ item.quantity }} - {{ item.unit_price }} - {{ item.total_price }} + {{ item.quantity }} {{ item.measurement }} + {{ item.unit_price }} {{ currency }} + {{ item.total_price }} {{ currency }} {% endfor %} @@ -117,19 +118,23 @@

{{ title }}

Subtotal - {{ subtotal }} + {{ subtotal }} {{ currency }} Discount ({{ discount_percentage }}%) -{{ discount_amount }} + + Concession ({{ concession_percentage }}%) + -{{ concession_amount }} + Tax ({{ tax_percentage }}%) +{{ tax_amount }} Grand Total - {{ grand_total }} + {{ grand_total }} {{ currency }} @@ -145,9 +150,19 @@

{{ title }}

-

Terms & Conditions:

-

{{ notes }}

+ {% for field in additional_fields %} +
+
+

{{ field.label }}:

+
    + {% for sentence in field.content|split_by_period %} +
  • {{ sentence }}
  • + {% endfor %} +
+
+
+ {% endfor %} diff --git a/sage_invoice/templates/quotation3.html b/sage_invoice/templates/quotation3.html index 74462a6..cfc002a 100644 --- a/sage_invoice/templates/quotation3.html +++ b/sage_invoice/templates/quotation3.html @@ -48,7 +48,7 @@
Grand Total:
- {{ grand_total }} + {{ grand_total }} {{ currency }}
Invoice Date:
@@ -87,9 +87,9 @@ {% for column in custom_columns %} {{ item.custom_data|get_item:column }} {% endfor %} - {{ item.quantity }} - {{ item.unit_price }} - {{ item.total_price }} + {{ item.quantity }} {{ item.measurement }} + {{ item.unit_price }} {{ currency }} + {{ item.total_price }} {{ currency }} {% endfor %} @@ -104,19 +104,23 @@ Subtotal - {{ subtotal }} + {{ subtotal }} {{currency}} Discount {{ discount_percentage }}% -{{ discount_amount }} + + Concession {{ concession_percentage }}% + -{{ concession_amount }} + Tax {{ tax_percentage }}% +{{ tax_amount }} Grand Total - {{ grand_total }} + {{ grand_total }} {{currency}} @@ -137,11 +141,19 @@
-
-
-

Terms & Conditions:

-

{{ notes }}

+ {% for field in additional_fields %} +
+
+

{{ field.label }}:

+
    + {% for sentence in field.content|split_by_period %} +
  • {{ sentence }}
  • + {% endfor %} +
+
+ {% endfor %} +
diff --git a/sage_invoice/templates/quotation4.html b/sage_invoice/templates/quotation4.html new file mode 100644 index 0000000..ee44fab --- /dev/null +++ b/sage_invoice/templates/quotation4.html @@ -0,0 +1,161 @@ + + +{% load static custom_filters %} + + + + + + + {{ title }} + + + + +
+
+
+
+
+
+ {% if logo_url %} + + {% endif %} +
+
+
{{ title }}
+
+
+
+
+
+

Invoice No: {{ tracking_code }}

+

Date: {{ invoice_date }}

+
+
+
+
+

Invoice To:

+

+ {{ customer_name }}
+ {{ customer_email }}
+ {{ customer_phone }} +

+
+
+

Pay To:

+

+ Your Company Name
+ Company Address
+

+
+
+ + +
+
+
+ + + + + + {% for column in custom_columns %} + + {% endfor %} + + + + + + + + {% for item in items %} + + + + {% for column in custom_columns %} + + {% endfor %} + + + + + + {% endfor %} + +
ItemDescription{{ column }}PriceQtyTotal
{{ forloop.counter }}. {{ item.description }}{{ item.custom_data|get_item:column }}{{ item.unit_price }} {{ currency }}{{ item.quantity }} {{ item.measurement }}{{ item.total_price }} {{ currency }}
+
+
+
+ + + + {% for field in additional_fields %} +
+
+

{{ field.label }}:

+
    + {% for sentence in field.content|split_by_period %} +
  • {{ sentence }}
  • + {% endfor %} +
+
+
+ {% endfor %} + + +
+
+ +
+
+ + + + + + diff --git a/sage_invoice/templates/receipt1.html b/sage_invoice/templates/receipt1.html index f22b7e3..85b6139 100644 --- a/sage_invoice/templates/receipt1.html +++ b/sage_invoice/templates/receipt1.html @@ -421,6 +421,7 @@ {% for column in custom_columns %} {{ column }} {% endfor %} + Currency Price Qty Total @@ -434,6 +435,7 @@ {% for column in custom_columns %} {{ item.custom_data|get_item:column }} {% endfor %} + {{ currency }} {{ item.unit_price }} {{ item.quantity }} {{ item.total_price }} diff --git a/sage_invoice/templates/sage_invoice/invoice1.jinja2 b/sage_invoice/templates/sage_invoice/invoice1.jinja2 index f17b31a..7ae8676 100644 --- a/sage_invoice/templates/sage_invoice/invoice1.jinja2 +++ b/sage_invoice/templates/sage_invoice/invoice1.jinja2 @@ -102,7 +102,7 @@ Subtotal - {{ subtotal }} + {{ subtotal }} {{ currency }} Discount {{ discount_percentage }}% @@ -114,7 +114,7 @@ Grand Total - {{ grand_total }} + {{ grand_total }} {{ currency }} @@ -132,9 +132,10 @@

Thank you for your business.

-

Terms & Condition

+

Notes

{{notes}}

+
diff --git a/sage_invoice/templates/sage_invoice/invoice4.jinja2 b/sage_invoice/templates/sage_invoice/invoice4.jinja2 new file mode 100644 index 0000000..94c735e --- /dev/null +++ b/sage_invoice/templates/sage_invoice/invoice4.jinja2 @@ -0,0 +1,142 @@ + + + + + + + + {{ title }} + + + +
+
+
+
+
+
+ {% if logo_url %} + + {% endif %} +
+
+
{{ title }}
+
+
+
+
+
+

Invoice No: {{ tracking_code }}

+

Date: {{ invoice_date }}

+
+
+
+
+

Invoice To:

+

+ {{ customer_name }}
+ {{ customer_email }}
+

+
+
+

Pay To:

+

+ Your Company Name
+ Company Address
+

+
+
+ +
+
+
+ + + + + + + + + {% for column in custom_columns %} + + {% endfor %} + + + + {% for item in items %} + + + + + + + {% for column in custom_columns %} + + {% endfor %} + + {% endfor %} + +
ItemDescriptionPriceQtyTotal{{ column }}
{{ loop.index }}. {{ item.description }}{{ item.custom_data.get('description', '') }}{{ item.unit_price }}{{ item.quantity }}{{ item.total_price }}{{ item.custom_data.get(column, '') }}
+
+
+
+ +
+

Terms & Conditions:

+
    + {% for term in additional_fields.get('Terms & Conditions', []) %} +
  • {{ term }}
  • + {% endfor %} +
+
+ +
+ {% for field in additional_fields %} +
+

{{ field.label }}:

+
    +
  • {{ field.content | safe }}
  • +
+
+ {% endfor %} +
+ + +
+
+
+
+ + + + + + diff --git a/sage_invoice/templatetags/custom_filters.py b/sage_invoice/templatetags/custom_filters.py index 772e7e4..bd43989 100644 --- a/sage_invoice/templatetags/custom_filters.py +++ b/sage_invoice/templatetags/custom_filters.py @@ -14,3 +14,11 @@ def subtract(value, arg): return float(value) - float(arg) except (ValueError, TypeError): return "" + + +@register.filter +def split_by_period(value): + """Splits a string by '.' and returns a list of sentences.""" + if isinstance(value, str): + return [sentence.strip() for sentence in value.split(".") if sentence.strip()] + return [] diff --git a/sage_invoice/tests/test_helpers.py b/sage_invoice/tests/test_helpers.py new file mode 100644 index 0000000..6b24ac2 --- /dev/null +++ b/sage_invoice/tests/test_helpers.py @@ -0,0 +1,69 @@ +import pytest +import secrets +from datetime import datetime +from unittest import mock +from sage_invoice.helpers.funcs import get_template_choices, generate_tracking_code +from sage_invoice.service.discovery import JinjaTemplateDiscovery + + +@pytest.mark.django_db +class TestHelperFunctions: + + @mock.patch("sage_invoice.helpers.funcs.JinjaTemplateDiscovery") + @mock.patch("sage_invoice.helpers.funcs.settings") + def test_get_template_choices_for_invoice(self, mock_settings, mock_discovery): + """Test template choices for an invoice (non-receipt).""" + mock_settings.SAGE_MODEL_TEMPLATE = "sage_invoice" + mock_discovery_instance = mock_discovery.return_value + mock_discovery_instance.SAGE_MODEL_TEMPLATEs = { + "template1": "/path/to/template1.jinja2", + "template2": "/path/to/template2.jinja2" + } + + choices = get_template_choices(is_receipt=False) + + assert len(choices) == 2 + assert choices[0][0] == "template1" + assert choices[0][1] == "Template1 Template" + assert choices[1][0] == "template2" + assert choices[1][1] == "Template2 Template" + + @mock.patch("sage_invoice.helpers.funcs.JinjaTemplateDiscovery") + @mock.patch("sage_invoice.helpers.funcs.settings") + def test_get_template_choices_for_receipt(self, mock_settings, mock_discovery): + """Test template choices for a receipt.""" + mock_settings.SAGE_MODEL_TEMPLATE = "sage_invoice" + mock_discovery_instance = mock_discovery.return_value + mock_discovery_instance.receipt_templates = { + "receipt1": "/path/to/receipt1.jinja2", + "receipt2": "/path/to/receipt2.jinja2" + } + + choices = get_template_choices(is_receipt=True) + + assert len(choices) == 2 + assert choices[0][0] == "receipt1" + assert choices[0][1] == "Receipt1 Template" + assert choices[1][0] == "receipt2" + assert choices[1][1] == "Receipt2 Template" + + def test_get_template_choices_no_templates(self): + """Test when no templates are available.""" + with mock.patch("sage_invoice.helpers.funcs.JinjaTemplateDiscovery") as mock_discovery: + mock_discovery_instance = mock_discovery.return_value + mock_discovery_instance.SAGE_MODEL_TEMPLATEs = {} + mock_discovery_instance.receipt_templates = {} + + choices = get_template_choices(is_receipt=False) + assert choices == [("", "No Templates Available")] + + @mock.patch("secrets.randbelow", return_value=1234) + def test_generate_tracking_code(self, mock_randbelow): + """Test the tracking code generation.""" + user_input = "INV" + creation_date = datetime(2024, 9, 1) + + tracking_code = generate_tracking_code(user_input, creation_date) + + assert tracking_code is not None + mock_randbelow.assert_called_once_with(8000) diff --git a/sage_invoice/tests/test_resource.py b/sage_invoice/tests/test_resource.py new file mode 100644 index 0000000..f0a79fe --- /dev/null +++ b/sage_invoice/tests/test_resource.py @@ -0,0 +1,186 @@ +import pytest +from unittest.mock import patch, MagicMock +from decimal import Decimal +from sage_invoice.models import Invoice, Item, Column, Expense +from sage_invoice.resource import InvoiceResource,Category,ItemResource, ExpenseResource + + +@pytest.mark.django_db +class TestInvoiceResource: + + @pytest.fixture + def invoice(self, db): + return Invoice.objects.create( + title="Test Invoice", + invoice_date="2024-09-10", + customer_name="John Doe", + status="unpaid", + tracking_code="INV-20240910", + due_date="2024-09-20" # Add the due_date here + ) + + @pytest.fixture + def invoice_item(self, invoice): + return Item.objects.create( + invoice=invoice, + description="Item 1", + quantity=2, + unit_price=Decimal("100.00"), + total_price=Decimal("200.00"), + ) + + @pytest.fixture + def invoice_column(self, invoice, invoice_item): + return Column.objects.create( + invoice=invoice, + item=invoice_item, + column_name="Column 1", + value="Value 1", + priority=1, + ) + + @pytest.fixture + def expense(self, invoice): + return Expense.objects.create( + invoice=invoice, + subtotal=Decimal("1000.00"), + tax_percentage=Decimal("10.00"), + tax_amount=Decimal("100.00"), + discount_percentage=Decimal("5.00"), + discount_amount=Decimal("50.00"), + total_amount=Decimal("1050.00"), + ) + + + @patch.object(Expense, 'objects') + def test_create_expenses(self, mock_objects, invoice): + """Test the create_expenses method.""" + mock_objects.update_or_create = MagicMock() + resource = InvoiceResource() + + resource.create_expenses("1000.00|10.00|5.00|0", invoice.id) + + mock_objects.update_or_create.assert_called_once_with( + invoice_id=invoice.id, + defaults={ + "subtotal": Decimal("1000.00"), + "tax_percentage": Decimal("10.00"), + "discount_percentage": Decimal("5.00"), + "tax_amount": Decimal("100.00"), + "discount_amount": Decimal("50.00"), + "total_amount": Decimal("1050.00"), + } + ) + + def test_dehydrate_items(self, invoice, invoice_item): + """Test the dehydrate_items method.""" + resource = InvoiceResource() + result = resource.dehydrate_items(invoice) + assert result is not None + + def test_dehydrate_columns(self, invoice, invoice_column): + """Test the dehydrate_columns method.""" + resource = InvoiceResource() + result = resource.dehydrate_columns(invoice) + assert result == "Column 1|Value 1|1" + + def test_dehydrate_totals(self, invoice, expense): + """Test the dehydrate_totals method.""" + resource = InvoiceResource() + result = resource.dehydrate_totals(invoice) + assert result == "1000.00|10.00|5.00|0.00" + + def test_dehydrate_contacts(self, invoice): + """Test the dehydrate_contacts method.""" + invoice.contacts = [{"email": "test@example.com", "phone": "123456789"}] + invoice.save() + + resource = InvoiceResource() + result = resource.dehydrate_contacts(invoice) + assert result == '[{"email": "test@example.com", "phone": "123456789"}]' + + +@pytest.mark.django_db +class TestItemResource: + + def test_meta(self): + """Test that ItemResource meta is set correctly.""" + resource = ItemResource() + assert resource.Meta.model == Item + assert resource.Meta.fields == ("id", "invoice", "description", "quantity", "unit_price", "total_price") + assert resource.Meta.import_id_fields == ["id"] + + +@pytest.mark.django_db +class TestExpenseResource: + + def test_meta(self): + """Test that ExpenseResource meta is set correctly.""" + resource = ExpenseResource() + assert resource.Meta.model == Expense + assert resource.Meta.fields == ("id", "invoice", "subtotal", "tax_percentage", "tax_amount", "discount_percentage", "discount_amount", "total_amount") + assert resource.Meta.import_id_fields == ["id"] + +import pytest +from unittest.mock import patch, MagicMock +from sage_invoice.models import Invoice, Category +from sage_invoice.resource import InvoiceResource + +@pytest.mark.django_db +class TestInvoiceResourceWithCategory: + + @pytest.fixture + def category(self): + """Fixture to create a category.""" + return Category.objects.create(title="Consulting", description="Consulting Services") + + @pytest.fixture + def invoice_with_category(self, category): + """Fixture to create an invoice with a category.""" + return Invoice.objects.create( + title="Invoice with Category", + invoice_date="2024-09-11", + customer_name="Jane Doe", + status="paid", + due_date="2024-09-30", + category=category, + ) + + @pytest.fixture + def invoice_without_category(self): + """Fixture to create an invoice without a category.""" + return Invoice.objects.create( + title="Invoice without Category", + invoice_date="2024-09-11", + customer_name="John Doe", + status="unpaid", + due_date="2024-09-30" + ) + + def test_dehydrate_category(self, invoice_with_category): + """Test the dehydrate_category method when a category exists.""" + resource = InvoiceResource() + result = resource.dehydrate_category(invoice_with_category) + assert result == "Consulting|Consulting Services" + + def test_dehydrate_category_no_category(self, invoice_without_category): + """Test the dehydrate_category method when no category is assigned.""" + resource = InvoiceResource() + result = resource.dehydrate_category(invoice_without_category) + assert result == "" + + def test_import_category_empty(self, invoice_without_category): + """Test that an empty category field does not assign a category.""" + resource = InvoiceResource() + + row = { + "id": invoice_without_category.id, + "category": "" # Empty category field + } + + # Simulate the after_import_row method + resource.after_import_row(row, None) + + # Ensure that the category is not assigned + invoice = Invoice.objects.get(id=row["id"]) + assert invoice.category is None diff --git a/sage_invoice/tests/test_service.py b/sage_invoice/tests/test_service.py index ed78228..064ee3e 100644 --- a/sage_invoice/tests/test_service.py +++ b/sage_invoice/tests/test_service.py @@ -1,171 +1,135 @@ -from datetime import datetime -from decimal import Decimal -from unittest import mock - import pytest +from unittest.mock import patch, MagicMock from jinja2.exceptions import TemplateNotFound - -from sage_invoice.models import Invoice, InvoiceCategory, InvoiceColumn, Expense -from sage_invoice.service.discovery import JinjaTemplateDiscovery from sage_invoice.service.invoice_create import QuotationService +from sage_invoice.models import Invoice, Item, Expense from sage_invoice.service.total import ExpenseService +from unittest import mock +from decimal import Decimal +from django.core.exceptions import ValidationError +@pytest.mark.django_db class TestQuotationService: + @patch('sage_invoice.service.invoice_create.JinjaTemplateDiscovery') + def test_init(self, mock_template_discovery): + # Test if QuotationService initializes correctly + service = QuotationService() + assert service.template_discovery == mock_template_discovery.return_value + assert service.env is not None + mock_template_discovery.assert_called_once_with( + models_dir="sage_invoice" # Default directory + ) + @pytest.fixture - def template_discovery(self): - with mock.patch( - "os.listdir", return_value=["invoice1.jinja2", "invoice2.jinja2"] - ): - return JinjaTemplateDiscovery(models_dir="test_templates") - - def test_get_template_path(self, template_discovery): - template_path = template_discovery.get_template_path("1") - assert template_path is not None - - def test_render_quotation(self, template_discovery): - invoice = mock.Mock() - invoice.template_choice = "invoice1" - invoice.items.all.return_value = [] - invoice.total.subtotal = 100.00 - invoice.total.tax_percentage = 10.00 - invoice.total.tax_amount = 10.00 - invoice.total.discount_percentage = 5.00 - invoice.total.discount_amount = 5.00 - invoice.total.total_amount = 105.00 - invoice.customer_name = "John Doe" - invoice.customer_email = "john.doe@example.com" - invoice.invoice_date = datetime(2024, 8, 25) - invoice.status = "unpaid" - invoice.notes = "Test notes" - invoice.logo.url = "test_logo_url" - invoice.signature.url = "test_signature_url" - invoice.stamp.url = "test_stamp_url" - invoice.receipt = False - - with mock.patch("jinja2.Environment.get_template") as mock_get_template: - mock_template = mock.Mock() - mock_template.render.return_value = "Rendered content" - mock_get_template.return_value = mock_template - - service = QuotationService() - service.template_discovery = template_discovery - - queryset = mock.Mock() - queryset.first.return_value = invoice - - rendered_content = service.render_quotation(queryset) - - mock_get_template.assert_called_once_with("invoice1.jinja2") - mock_template.render.assert_called_once() - assert rendered_content == "Rendered content" - - def test_invalid_quotation(self, template_discovery): - invoice = mock.Mock() - invoice.template_choice = "NOTFUND" - invoice.items.all.return_value = [] - invoice.total.subtotal = 100.00 - invoice.total.tax_percentage = 10.00 - invoice.total.tax_amount = 10.00 - invoice.total.discount_percentage = 5.00 - invoice.total.discount_amount = 5.00 - invoice.total.total_amount = 105.00 - invoice.customer_name = "John Doe" - invoice.customer_email = "john.doe@example.com" - invoice.invoice_date = datetime(2024, 8, 25) - invoice.status = "unpaid" - invoice.notes = "Test notes" - invoice.logo.url = "test_logo_url" - invoice.signature.url = "test_signature_url" - invoice.stamp.url = "test_stamp_url" - - with mock.patch("jinja2.Environment.get_template") as mock_get_template: - mock_template = mock.Mock() - mock_template.render.return_value = "Rendered content" - mock_get_template.return_value = mock_template - - service = QuotationService() - service.template_discovery = template_discovery - - queryset = mock.Mock() - queryset.first.return_value = invoice - with pytest.raises(TemplateNotFound): - service.render_quotation(queryset) - - def test_render_quotation_with_items(self, template_discovery): - invoice = mock.Mock() - invoice.template_choice = "invoice1" - - item1 = mock.Mock( - description="Item 1", quantity=1, unit_price=100.00, total_price=100.00 + def invoice(self, db): + # Create a sample invoice and related items + invoice = Invoice.objects.create( + title="Test Invoice", + invoice_date="2024-09-10", + customer_name="Test Customer", + tracking_code="INV-2024", + status="unpaid", + currency="USD", + due_date="2024-10-10" ) - item2 = mock.Mock( - description="Item 2", quantity=2, unit_price=50.00, total_price=100.00 + Expense.objects.create( + invoice=invoice, + subtotal=100, + tax_percentage=10, + discount_percentage=5, + concession_percentage=0, + tax_amount=10, + discount_amount=5, + concession_amount=0, + total_amount=105 + ) + Item.objects.create( + invoice=invoice, + description="Test Item", + quantity=1, + unit_price=100, + total_price=100 ) - invoice.items.all.return_value = [item1, item2] - - mock_query_set = mock.Mock() - mock_query_set.order_by.return_value = [ - mock.Mock(column_name="Delivery Date", value="2024-09-01"), - mock.Mock(column_name="Warranty Period", value="12 months"), - ] - InvoiceColumn.objects.filter = mock.Mock(return_value=mock_query_set) - - invoice.total.subtotal = 200.00 - invoice.total.tax_percentage = 10.00 - invoice.total.tax_amount = 20.00 - invoice.total.discount_percentage = 5.00 - invoice.total.discount_amount = 10.00 - invoice.total.total_amount = 210.00 - invoice.customer_name = "John Doe" - invoice.customer_email = "john.doe@example.com" - invoice.invoice_date = datetime(2024, 8, 25) - invoice.status = "unpaid" - invoice.notes = "Test notes" - invoice.logo.url = "test_logo_url" - invoice.signature.url = "test_signature_url" - invoice.stamp.url = "test_stamp_url" - invoice.receipt = False - with mock.patch("jinja2.Environment.get_template") as mock_get_template: - mock_template = mock.Mock() - mock_template.render.return_value = "Rendered content" - mock_get_template.return_value = mock_template - - service = QuotationService() - service.template_discovery = template_discovery - - queryset = mock.Mock() - queryset.first.return_value = invoice - - rendered_content = service.render_quotation(queryset) - - mock_get_template.assert_called_once_with("invoice1.jinja2") - mock_template.render.assert_called_once() - - InvoiceColumn.objects.filter.assert_called() - assert rendered_content == "Rendered content" + return invoice + @patch('sage_invoice.service.invoice_create.JinjaTemplateDiscovery.get_template_path') + def test_render_quotation_success(self, mock_get_template_path, invoice): + # Mock template discovery and rendering process + mock_template = MagicMock() + mock_template.render.return_value = "Rendered HTML" + mock_get_template_path.return_value = "path/to/template.html" + + service = QuotationService() + service.env.get_template = MagicMock(return_value=mock_template) + + # Mock the queryset with first method + mock_queryset = MagicMock() + mock_queryset.first.return_value = invoice + + result = service.render_quotation(mock_queryset) + + assert result == "Rendered HTML" + mock_get_template_path.assert_called_once() + service.env.get_template.assert_called_once_with("template.html") + mock_template.render.assert_called_once() + + @patch('sage_invoice.service.invoice_create.JinjaTemplateDiscovery.get_template_path') + def test_render_quotation_template_not_found(self, mock_get_template_path, invoice): + # Test case when template is not found + mock_get_template_path.return_value = None + service = QuotationService() + + # Mock the queryset with first method + mock_queryset = MagicMock() + mock_queryset.first.return_value = invoice + + with pytest.raises(TemplateNotFound): + service.render_quotation(mock_queryset) + + mock_get_template_path.assert_called_once() + + def test_render_contax(self, invoice): + # Test context generation for the invoice + service = QuotationService() + + # Ensure contacts and other fields are handled correctly + invoice.contacts = ["you@example.com", "1234567890"] + context = service.render_contax(invoice) + + assert context["title"] == invoice.title + assert context["tracking_code"] == invoice.tracking_code + assert context["subtotal"] == invoice.total.subtotal + assert context["grand_total"] == invoice.total.total_amount + assert context["customer_name"] == invoice.customer_name + assert len(context["items"]) == invoice.items.count() + + def test_invalid_invoice_due_date(self): + # Test validation on due date + invoice = Invoice( + title="Invalid Invoice", + invoice_date="2024-09-10", + due_date="2024-09-05", + customer_name="Test Customer", + tracking_code="INV-2024", + status="unpaid", + currency="USD" + ) + with pytest.raises(ValidationError): + invoice.clean() +@pytest.mark.django_db class TestExpenseService: @pytest.fixture - def invoice_category(self, db): - """Fixture to create a real InvoiceCategory instance.""" - return InvoiceCategory.objects.create( - title="Default Category", description="A default category for testing." - ) - - @pytest.fixture - def invoice(self, db, invoice_category): + def invoice(self, db): """Fixture to create a real Invoice instance with items.""" invoice = Invoice.objects.create( title="Test Invoice", invoice_date="2024-08-25", customer_name="John Doe", - customer_email="john.doe@example.com", status="unpaid", - category=invoice_category, due_date="2024-09-01", ) invoice.items.create( @@ -192,21 +156,20 @@ def test_calculate_and_save(self, invoice_total): with mock.patch.object(Expense, "save") as mock_save: service.calculate_and_save(invoice_total) + # Verify calculations assert invoice_total.subtotal == Decimal("200.00") assert invoice_total.tax_amount == Decimal("20.00") assert invoice_total.discount_amount == Decimal("10.00") assert invoice_total.total_amount == Decimal("210.00") mock_save.assert_called_once() - def test_calculate_and_save_with_no_items(self, db, invoice_category): + def test_calculate_and_save_with_no_items(self, db): """Test calculate_and_save when the invoice has no items.""" invoice = Invoice.objects.create( title="Empty Invoice", invoice_date="2024-08-25", customer_name="John Doe", - customer_email="john.doe@example.com", status="unpaid", - category=invoice_category, due_date="2024-09-01", ) @@ -218,9 +181,10 @@ def test_calculate_and_save_with_no_items(self, db, invoice_category): service = ExpenseService() - with mock.patch.object(Expense, "save") as mock_save: + with mock.patch.object(invoice_total, "save") as mock_save: service.calculate_and_save(invoice_total) + # Verify calculations with no items assert invoice_total.subtotal == Decimal("0.00") assert invoice_total.tax_amount == Decimal("0.00") assert invoice_total.discount_amount == Decimal("0.00") diff --git a/sage_invoice/tests/test_signal.py b/sage_invoice/tests/test_signal.py new file mode 100644 index 0000000..635acc0 --- /dev/null +++ b/sage_invoice/tests/test_signal.py @@ -0,0 +1,73 @@ +import pytest +from unittest import mock +from django.db import IntegrityError, OperationalError, transaction +from sage_invoice.models import Invoice, Expense +from sage_invoice.service.total import ExpenseService +from sage_invoice.signals import update_invoice_total_on_save +from django.core.exceptions import ValidationError + + +@pytest.mark.django_db +class TestInvoiceSignals: + + @pytest.fixture + def invoice(self, db): + """Fixture to create a real Invoice instance.""" + return Invoice.objects.create( + title="Test Invoice", + invoice_date="2024-09-01", + customer_name="John Doe", + status="unpaid", + due_date="2024-09-15", + ) + + @mock.patch.object(ExpenseService, "calculate_and_save") + @mock.patch("sage_invoice.signals.transaction.on_commit") + def test_update_invoice_total_on_save_success(self, mock_on_commit, mock_calculate_and_save, invoice): + """Test that the signal correctly calculates and saves the invoice total.""" + Expense.objects.create(invoice=invoice, subtotal=100, total_amount=100) + + # Trigger the signal + update_invoice_total_on_save(Invoice, invoice, created=False) + + # Verify that the recalculate_total function is registered with on_commit + mock_on_commit.assert_called_once() + + # Call the recalculate_total function manually + recalculate_total_fn = mock_on_commit.call_args[0][0] + recalculate_total_fn() # Simulate transaction commit + + mock_calculate_and_save.assert_called_once() + + @mock.patch.object(ExpenseService, "calculate_and_save") + @mock.patch("sage_invoice.signals.transaction.on_commit") + def test_update_invoice_total_on_save_transaction_error(self, mock_on_commit, mock_calculate_and_save, invoice): + """Test that transaction errors are handled correctly.""" + mock_on_commit.side_effect = OperationalError("Transaction failed") + + # Trigger the signal + update_invoice_total_on_save(Invoice, invoice, created=False) + + mock_calculate_and_save.assert_not_called() + + @mock.patch.object(ExpenseService, "calculate_and_save") + @mock.patch("sage_invoice.signals.transaction.on_commit") + def test_update_invoice_total_on_save_integrity_error(self, mock_on_commit, mock_calculate_and_save, invoice): + """Test that integrity errors are handled correctly.""" + mock_on_commit.side_effect = IntegrityError("Integrity failed") + + # Trigger the signal + update_invoice_total_on_save(Invoice, invoice, created=False) + + mock_calculate_and_save.assert_not_called() + + @mock.patch.object(ExpenseService, "calculate_and_save") + @mock.patch("sage_invoice.signals.transaction.on_commit") + def test_update_invoice_total_on_save_validation_error(self, mock_on_commit, mock_calculate_and_save, invoice): + """Test that validation errors are handled correctly.""" + mock_on_commit.side_effect = ValidationError("Validation failed") + + # Trigger the signal + update_invoice_total_on_save(Invoice, invoice, created=False) + + mock_calculate_and_save.assert_not_called() diff --git a/sage_invoice/urls.py b/sage_invoice/urls.py index 34adb9a..9fbd8fa 100644 --- a/sage_invoice/urls.py +++ b/sage_invoice/urls.py @@ -1,6 +1,11 @@ from django.urls import path -from sage_invoice.views.invoice import InvoiceDetailView, TemplateChoiceView +from sage_invoice.views.invoice import ( + DownloadInvoicesView, + GenerateInvoicesView, + InvoiceDetailView, + TemplateChoiceView, +) urlpatterns = [ path( @@ -13,4 +18,8 @@ TemplateChoiceView.as_view(), name="template_choices", ), + path("generate-pdfs/", GenerateInvoicesView.as_view(), name="generate_pdfs"), + path( + "download-invoices/", DownloadInvoicesView.as_view(), name="download_invoices" + ), ] diff --git a/sage_invoice/views/invoice.py b/sage_invoice/views/invoice.py index 29c39c0..287fe51 100644 --- a/sage_invoice/views/invoice.py +++ b/sage_invoice/views/invoice.py @@ -1,4 +1,8 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import PermissionDenied from django.http import JsonResponse +from django.shortcuts import render +from django.template.loader import render_to_string from django.views import View from django.views.generic import TemplateView @@ -7,20 +11,36 @@ from sage_invoice.service.invoice_create import QuotationService -class InvoiceDetailView(TemplateView): +class InvoiceDetailView(LoginRequiredMixin, TemplateView): template_name = "" + permission_denied_message = ( + "No access - You do not have permission to view this page." + ) + + # Define where to redirect the user if not authenticated + login_url = "admin/login/" # Replace with your actual login URL + + def dispatch(self, request, *args, **kwargs): + # Check if the user is staff before rendering the page + if not request.user.is_staff: + raise PermissionDenied() + + return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) invoice_slug = self.kwargs.get("slug") - invoice = Invoice.objects.filter(slug=invoice_slug) + invoice = Invoice.objects.filter(slug=invoice_slug).first() service = QuotationService() rendered_content = service.render_contax(invoice) context.update(rendered_content) - if invoice.first().receipt: - self.template_name = f"receipt{invoice.first().template_choice}.html" + + # Dynamically choose the template based on invoice type and choice + if invoice.receipt: + self.template_name = f"receipt{invoice.template_choice}.html" else: - self.template_name = f"quotation{invoice.first().template_choice}.html" + self.template_name = f"quotation{invoice.template_choice}.html" + return context @@ -36,3 +56,55 @@ def get(self, request, *args, **kwargs): {"value": choice[0], "label": choice[1]} for choice in choices ] return JsonResponse(formatted_choices, safe=False) + + +class GenerateInvoicesView(TemplateView): + """ + This view generates multiple invoices and returns their rendered HTML for PDF + generation. + """ + + def get(self, request, *args, **kwargs): + invoice_ids = request.GET.get("invoice_ids", "") + invoice_ids = invoice_ids.split(",") if invoice_ids else [] + + if not invoice_ids: + return JsonResponse({"error": "No invoice IDs provided"}, status=400) + + invoices_data = [] + + for invoice_id in invoice_ids: + invoice = Invoice.objects.filter(id=invoice_id).first() + if not invoice: + continue + + service = QuotationService() + rendered_content = service.render_contax(invoice) + + # Determine the template based on invoice type + if invoice.receipt: + template_name = f"receipt{invoice.template_choice}.html" + else: + template_name = f"quotation{invoice.template_choice}.html" + + rendered_html = render_to_string(template_name, rendered_content) + + invoices_data.append( + { + "id": invoice.id, + "title": invoice.title or f"Invoice_{invoice.id}", + "rendered_html": rendered_html, + } + ) + + return JsonResponse({"invoices": invoices_data}) + + +class DownloadInvoicesView(View): + def get(self, request, *args, **kwargs): + # Get the invoice IDs from the query parameters + invoice_ids = request.GET.get("invoice_ids", "") + + context = {"invoice_ids": invoice_ids} + + return render(request, "download_invoices.html", context)