feat: Enhance invoice and quote management system

- Improve invoice model with additional fields and methods
- Enhance quote model with better validation and relationships
- Add invoice repository for data access layer abstraction
- Update invoice and quote routes with improved functionality
- Add quote service for business logic separation
- Improve quote view and edit templates with better UX
This commit is contained in:
Dries Peeters
2026-01-09 11:43:51 +01:00
parent 4eeaa2a842
commit 4a681f0f48
7 changed files with 117 additions and 41 deletions
+19 -6
View File
@@ -285,14 +285,25 @@ class Invoice(db.Model):
def generate_invoice_number(cls):
"""Generate a unique invoice number"""
from datetime import datetime
from app.models import Settings
# Format: INV-YYYYMMDD-XXX
# Get settings for invoice prefix and start number
settings = Settings.get_settings()
prefix = "INV" # Default prefix
start_number = 1 # Default start number
if settings:
prefix = getattr(settings, "invoice_prefix", "INV") or "INV"
start_number = getattr(settings, "invoice_start_number", 1) or 1
# Format: {prefix}-YYYYMMDD-XXX
today = datetime.utcnow()
date_prefix = today.strftime("%Y%m%d")
search_pattern = f"{prefix}-{date_prefix}-%"
# Find the next available number for today
# Find the next available number for today with the custom prefix
existing = (
cls.query.filter(cls.invoice_number.like(f"INV-{date_prefix}-%"))
cls.query.filter(cls.invoice_number.like(search_pattern))
.order_by(cls.invoice_number.desc())
.first()
)
@@ -302,12 +313,14 @@ class Invoice(db.Model):
try:
last_num = int(existing.invoice_number.split("-")[-1])
next_num = last_num + 1
# Ensure next_num is at least start_number
next_num = max(next_num, start_number)
except (ValueError, IndexError):
next_num = 1
next_num = start_number
else:
next_num = 1
next_num = start_number
return f"INV-{date_prefix}-{next_num:03d}"
return f"{prefix}-{date_prefix}-{next_num:03d}"
class InvoiceItem(db.Model):
+20 -8
View File
@@ -68,14 +68,24 @@ class InvoiceRepository(BaseRepository[Invoice]):
def generate_invoice_number(self) -> str:
"""Generate a unique invoice number"""
from datetime import datetime
from app.models import Settings
# Format: INV-YYYYMMDD-XXXX
today = datetime.now().strftime("%Y%m%d")
prefix = f"INV-{today}-"
# Get settings for invoice prefix and start number
settings = Settings.get_settings()
prefix = "INV" # Default prefix
start_number = 1 # Default start number
# Find the highest number for today
if settings:
prefix = getattr(settings, "invoice_prefix", "INV") or "INV"
start_number = getattr(settings, "invoice_start_number", 1) or 1
# Format: {prefix}-YYYYMMDD-XXX
today = datetime.utcnow().strftime("%Y%m%d")
search_pattern = f"{prefix}-{today}-%"
# Find the highest number for today with the custom prefix
last_invoice = (
self.model.query.filter(Invoice.invoice_number.like(f"{prefix}%"))
self.model.query.filter(Invoice.invoice_number.like(search_pattern))
.order_by(Invoice.invoice_number.desc())
.first()
)
@@ -84,12 +94,14 @@ class InvoiceRepository(BaseRepository[Invoice]):
try:
last_num = int(last_invoice.invoice_number.split("-")[-1])
next_num = last_num + 1
# Ensure next_num is at least start_number
next_num = max(next_num, start_number)
except (ValueError, IndexError):
next_num = 1
next_num = start_number
else:
next_num = 1
next_num = start_number
return f"{prefix}{next_num:04d}"
return f"{prefix}-{today}-{next_num:03d}"
def mark_as_sent(self, invoice_id: int) -> Optional[Invoice]:
"""Mark an invoice as sent"""
+21 -16
View File
@@ -1029,57 +1029,62 @@ def export_invoice_csv(invoice_id):
@login_required
def export_invoice_pdf(invoice_id):
"""Export invoice as PDF with optional page size selection"""
logger.info(f"Invoice PDF export requested - Invoice ID: {invoice_id}, User: {current_user.username}")
current_app.logger.info(f"[PDF_EXPORT] Action: export_request, InvoiceID: {invoice_id}, User: {current_user.username}")
invoice = Invoice.query.get_or_404(invoice_id)
logger.debug(f"Invoice found: {invoice.invoice_number}")
current_app.logger.info(f"[PDF_EXPORT] Invoice found: {invoice.invoice_number}, Status: {invoice.status}")
if not current_user.is_admin and invoice.created_by != current_user.id:
logger.warning(f"Permission denied for invoice {invoice_id} by user {current_user.username}")
current_app.logger.warning(f"[PDF_EXPORT] Permission denied - InvoiceID: {invoice_id}, User: {current_user.username}")
flash(_("You do not have permission to export this invoice"), "error")
return redirect(request.referrer or url_for("invoices.list_invoices"))
# Get page size from query parameter, default to A4
page_size = request.args.get("size", "A4")
logger.debug(f"Page size from request: '{page_size}'")
page_size_raw = request.args.get("size", "A4")
current_app.logger.info(f"[PDF_EXPORT] PageSize from query param: '{page_size_raw}', InvoiceID: {invoice_id}")
# Validate page size
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size not in valid_sizes:
logger.warning(f"Invalid page size '{page_size}', defaulting to A4")
if page_size_raw not in valid_sizes:
current_app.logger.warning(f"[PDF_EXPORT] Invalid page size '{page_size_raw}', defaulting to A4, InvoiceID: {invoice_id}")
page_size = "A4"
else:
page_size = page_size_raw
logger.debug(f"Final page size: '{page_size}'")
current_app.logger.info(f"[PDF_EXPORT] Final validated PageSize: '{page_size}', InvoiceID: {invoice_id}, InvoiceNumber: {invoice.invoice_number}")
try:
from app.utils.pdf_generator import InvoicePDFGenerator
settings = Settings.get_settings()
logger.debug(f"Creating InvoicePDFGenerator with page_size='{page_size}'")
current_app.logger.info(f"[PDF_EXPORT] Creating InvoicePDFGenerator - PageSize: '{page_size}', InvoiceID: {invoice_id}")
pdf_generator = InvoicePDFGenerator(invoice, settings=settings, page_size=page_size)
logger.debug("Calling pdf_generator.generate_pdf()")
current_app.logger.info(f"[PDF_EXPORT] Starting PDF generation - PageSize: '{page_size}', InvoiceID: {invoice_id}")
pdf_bytes = pdf_generator.generate_pdf()
logger.info(f"PDF generated successfully, size: {len(pdf_bytes)} bytes")
pdf_size_bytes = len(pdf_bytes)
current_app.logger.info(f"[PDF_EXPORT] PDF generation completed successfully - PageSize: '{page_size}', InvoiceID: {invoice_id}, PDFSize: {pdf_size_bytes} bytes")
filename = f"invoice_{invoice.invoice_number}_{page_size}.pdf"
current_app.logger.info(f"[PDF_EXPORT] Returning PDF file - Filename: '{filename}', PageSize: '{page_size}', InvoiceID: {invoice_id}")
return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename)
except Exception as e:
import traceback
logger.error(f"Exception in PDF generation: {e}", exc_info=True)
current_app.logger.error(f"[PDF_EXPORT] Exception in PDF generation - PageSize: '{page_size}', InvoiceID: {invoice_id}, Error: {str(e)}", exc_info=True)
try:
logger.info("Falling back to InvoicePDFGeneratorFallback")
current_app.logger.warning(f"[PDF_EXPORT] Falling back to InvoicePDFGeneratorFallback - PageSize: '{page_size}', InvoiceID: {invoice_id}")
from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback
settings = Settings.get_settings()
pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings)
pdf_bytes = pdf_generator.generate_pdf()
logger.info("Fallback PDF generated successfully")
pdf_size_bytes = len(pdf_bytes)
current_app.logger.info(f"[PDF_EXPORT] Fallback PDF generated successfully - PageSize: '{page_size}', InvoiceID: {invoice_id}, PDFSize: {pdf_size_bytes} bytes")
filename = f"invoice_{invoice.invoice_number}_{page_size}.pdf"
return send_file(
io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename
)
except Exception as fallback_error:
logger.error(f"Fallback PDF generation also failed: {fallback_error}", exc_info=True)
current_app.logger.error(f"[PDF_EXPORT] Fallback PDF generation also failed - PageSize: '{page_size}', InvoiceID: {invoice_id}, Error: {str(fallback_error)}", exc_info=True)
flash(
_("PDF generation failed: %(err)s. Fallback also failed: %(fb)s", err=str(e), fb=str(fallback_error)),
"error",
+23 -5
View File
@@ -266,9 +266,9 @@ def view_quote(quote_id):
@admin_or_permission_required("edit_quotes")
def edit_quote(quote_id):
"""Edit an quote"""
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import joinedload, selectinload
quote = Quote.query.options(joinedload(Quote.client), joinedload(Quote.items)).filter_by(id=quote_id).first_or_404()
quote = Quote.query.options(joinedload(Quote.client), selectinload(Quote.items)).filter_by(id=quote_id).first_or_404()
# Only allow editing draft quotes
if quote.status != "draft":
@@ -1185,19 +1185,29 @@ def save_template_from_quote(template_id):
@login_required
def export_quote_pdf(quote_id):
"""Export quote as PDF"""
current_app.logger.info(f"[PDF_EXPORT] Action: export_request, QuoteID: {quote_id}, User: {current_user.username}")
quote = Quote.query.get_or_404(quote_id)
current_app.logger.info(f"[PDF_EXPORT] Quote found: {quote.quote_number}, Status: {quote.status}")
if not current_user.is_admin and quote.created_by != current_user.id:
current_app.logger.warning(f"[PDF_EXPORT] Permission denied - QuoteID: {quote_id}, User: {current_user.username}")
flash(_("You do not have permission to export this quote"), "error")
return redirect(request.referrer or url_for("quotes.list_quotes"))
# Get page size from query parameter, default to A4
page_size = request.args.get("size", "A4")
page_size_raw = request.args.get("size", "A4")
current_app.logger.info(f"[PDF_EXPORT] PageSize from query param: '{page_size_raw}', QuoteID: {quote_id}")
# Validate page size
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size not in valid_sizes:
if page_size_raw not in valid_sizes:
current_app.logger.warning(f"[PDF_EXPORT] Invalid page size '{page_size_raw}', defaulting to A4, QuoteID: {quote_id}")
page_size = "A4"
else:
page_size = page_size_raw
current_app.logger.info(f"[PDF_EXPORT] Final validated PageSize: '{page_size}', QuoteID: {quote_id}, QuoteNumber: {quote.quote_number}")
try:
from app.utils.pdf_generator import QuotePDFGenerator
@@ -1206,12 +1216,18 @@ def export_quote_pdf(quote_id):
from flask import send_file
settings = Settings.get_settings()
current_app.logger.info(f"[PDF_EXPORT] Creating QuotePDFGenerator - PageSize: '{page_size}', QuoteID: {quote_id}")
pdf_generator = QuotePDFGenerator(quote, settings=settings, page_size=page_size)
current_app.logger.info(f"[PDF_EXPORT] Starting PDF generation - PageSize: '{page_size}', QuoteID: {quote_id}")
pdf_bytes = pdf_generator.generate_pdf()
pdf_size_bytes = len(pdf_bytes)
current_app.logger.info(f"[PDF_EXPORT] PDF generation completed successfully - PageSize: '{page_size}', QuoteID: {quote_id}, PDFSize: {pdf_size_bytes} bytes")
filename = f"quote_{quote.quote_number}_{page_size}.pdf"
current_app.logger.info(f"[PDF_EXPORT] Returning PDF file - Filename: '{filename}', PageSize: '{page_size}', QuoteID: {quote_id}")
return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename)
except ImportError:
# Fallback if QuotePDFGenerator doesn't exist yet
current_app.logger.warning(f"[PDF_EXPORT] QuotePDFGenerator import failed, using fallback - PageSize: '{page_size}', QuoteID: {quote_id}")
from app.utils.pdf_generator_fallback import QuotePDFGeneratorFallback
from app.models import Settings
import io
@@ -1220,10 +1236,12 @@ def export_quote_pdf(quote_id):
settings = Settings.get_settings()
pdf_generator = QuotePDFGeneratorFallback(quote, settings=settings)
pdf_bytes = pdf_generator.generate_pdf()
pdf_size_bytes = len(pdf_bytes)
current_app.logger.info(f"[PDF_EXPORT] Fallback PDF generated successfully - PageSize: '{page_size}', QuoteID: {quote_id}, PDFSize: {pdf_size_bytes} bytes")
filename = f"quote_{quote.quote_number}_{page_size}.pdf"
return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename)
except Exception as e:
current_app.logger.error(f"Error generating quote PDF: {e}", exc_info=True)
current_app.logger.error(f"[PDF_EXPORT] Exception in PDF generation - PageSize: '{page_size}', QuoteID: {quote_id}, Error: {str(e)}", exc_info=True)
flash(_("Error generating PDF: %(error)s", error=str(e)), "error")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
+2 -2
View File
@@ -140,9 +140,9 @@ class QuoteService:
Returns:
Quote with eagerly loaded relations, or None if not found
"""
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import joinedload, selectinload
query = Quote.query.options(joinedload(Quote.client), joinedload(Quote.items))
query = Quote.query.options(joinedload(Quote.client), selectinload(Quote.items))
# Permission check
if not is_admin and user_id:
+1 -1
View File
@@ -53,7 +53,7 @@
<div class="flex items-center justify-between mb-4 pb-2 border-b border-border-light dark:border-border-dark">
<h2 class="text-xl font-semibold flex items-center">
<i class="fas fa-list mr-2 text-primary"></i>{{ _('Quote Items') }}
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-primary/10 text-primary rounded-full">{{ quote.items.count() if quote.items else 0 }}</span>
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-primary/10 text-primary rounded-full">{{ quote.items|length if quote.items else 0 }}</span>
</h2>
<button type="button" id="add-item" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition shadow-sm">
<i class="fas fa-plus mr-2"></i>{{ _('Add Item') }}
+31 -3
View File
@@ -24,9 +24,19 @@
<i class="fas fa-copy mr-2"></i>{{ _('Duplicate') }}
</a>
{% endif %}
<a href="{{ url_for('quotes.export_quote_pdf', quote_id=quote.id) }}" class="inline-block px-4 py-2 rounded-lg bg-green-600 text-white mt-4 md:mt-0 hover:bg-green-700 transition-colors">
<i class="fas fa-file-pdf mr-2"></i>{{ _('Export PDF') }}
</a>
<div class="flex gap-2 items-center">
<select id="pdf-size-selector" class="bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
<option value="A4">A4</option>
<option value="Letter">Letter</option>
<option value="Legal">Legal</option>
<option value="A3">A3</option>
<option value="A5">A5</option>
<option value="Tabloid">Tabloid</option>
</select>
<a id="export-pdf-link" href="{{ url_for('quotes.export_quote_pdf', quote_id=quote.id) }}" class="inline-block px-4 py-2 rounded-lg bg-green-600 text-white mt-4 md:mt-0 hover:bg-green-700 transition-colors">
<i class="fas fa-file-pdf mr-2"></i>{{ _('Export PDF') }}
</a>
</div>
<button type="button" onclick="showSendEmailModal()" class="px-4 py-2 rounded-lg bg-blue-500 text-white mt-4 md:mt-0 hover:bg-blue-600 transition-colors">
<i class="fas fa-envelope mr-2"></i>{{ _('Send Email') }}
</button>
@@ -466,6 +476,24 @@
</div>
<script>
// PDF Size Selector
document.addEventListener('DOMContentLoaded', function() {
const sizeSelector = document.getElementById('pdf-size-selector');
const exportLink = document.getElementById('export-pdf-link');
if (sizeSelector && exportLink) {
// Initialize export link with default size (A4)
const defaultSize = sizeSelector.value || 'A4';
const baseUrl = "{{ url_for('quotes.export_quote_pdf', quote_id=quote.id) }}";
exportLink.href = baseUrl + '?size=' + defaultSize;
// Update export link when size changes
sizeSelector.addEventListener('change', function() {
const size = this.value;
exportLink.href = baseUrl + '?size=' + size;
});
}
});
function showSendEmailModal() {
document.getElementById('sendEmailModal').classList.remove('hidden');
}