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)