PDF Layout Editor: local GrapesJS, admin UI, i18n, preview fixes

Add admin PDF Layout Editor with local GrapesJS (no CDN)
Routes:
GET/POST /admin/pdf-layout (save, server-side default seeding)
POST /admin/pdf-layout/reset (clear custom template)
GET /admin/pdf-layout/default (serve default body HTML/CSS)
POST /admin/pdf-layout/preview (render preview with sample context)
Invoice PDF generator: support custom HTML/CSS and i18n; add default template and CSS
Preview: sanitize Jinja, add helpers (format_date, format_money), sample item
Base layout: include head_extra and scripts_extra
Editor UI: removed quick blocks, preview, and insert variables; keep load/save/reset
Vendor GrapesJS under app/static/vendor/grapesjs and load locally
README: document the new feature and usage
This commit is contained in:
Dries Peeters
2025-09-12 14:35:08 +02:00
parent 016fe5ead0
commit 4ef035dc78
15 changed files with 828 additions and 53 deletions
+17
View File
@@ -398,6 +398,23 @@ See [Version Management Documentation](docs/VERSION_MANAGEMENT.md) for detailed
- **CLI & Admin Tools**: Database migrations, management scripts, and utilities
- **Dockerized Deployment**: Local and remote compose files, public images on GitHub Container Registry
### New: PDF Layout Editor (Admin)
- **Visual editor** to customize the invoice PDF layout with HTML and CSS
- **Local assets**: GrapesJS is served from `static/vendor` (no CDN dependency)
- **Live preview** in the editor to validate changes before export
- **Translations**: Fully supports FlaskBabel strings like `{{ _('Invoice') }}`
- **Company branding**: Uses values from Admin → System Settings (logo, address, etc.)
- **Safe defaults**: Oneclick “Load Defaults” provides a complete starter template
Usage:
1. Open `Admin → System Settings` and click “Edit PDF Layout”.
2. Adjust HTML and CSS. Use `{{ format_date(...) }}`, `{{ format_money(...) }}`, `{{ _('...') }}`.
3. Click “Save Layout”. Export any invoice to see the new design.
Notes:
- The editor stores custom template HTML/CSS in the `settings` table; leave blank to use builtin defaults.
- Preview sanitizes pasted content to avoid smart quotes and HTML entities breaking Jinja.
## 📚 Documentation
Detailed documentation is available in the `docs/` directory:
+10
View File
@@ -29,6 +29,10 @@ class Settings(db.Model):
company_tax_id = db.Column(db.String(100), default='', nullable=True)
company_bank_info = db.Column(db.Text, default='', nullable=True)
# PDF template customization
invoice_pdf_template_html = db.Column(db.Text, default='', nullable=True)
invoice_pdf_template_css = db.Column(db.Text, default='', nullable=True)
# Invoice defaults
invoice_prefix = db.Column(db.String(10), default='INV', nullable=False)
invoice_start_number = db.Column(db.Integer, default=1000, nullable=False)
@@ -63,6 +67,10 @@ class Settings(db.Model):
self.company_tax_id = kwargs.get('company_tax_id', '')
self.company_bank_info = kwargs.get('company_bank_info', '')
# PDF template customization
self.invoice_pdf_template_html = kwargs.get('invoice_pdf_template_html', '')
self.invoice_pdf_template_css = kwargs.get('invoice_pdf_template_css', '')
# Set invoice defaults
self.invoice_prefix = kwargs.get('invoice_prefix', 'INV')
self.invoice_start_number = kwargs.get('invoice_start_number', 1000)
@@ -127,6 +135,8 @@ class Settings(db.Model):
'invoice_start_number': self.invoice_start_number,
'invoice_terms': self.invoice_terms,
'invoice_notes': self.invoice_notes,
'invoice_pdf_template_html': self.invoice_pdf_template_html,
'invoice_pdf_template_css': self.invoice_pdf_template_css,
'allow_analytics': self.allow_analytics,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
+222 -2
View File
@@ -1,8 +1,8 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file, jsonify, render_template_string
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db
from app.models import User, Project, TimeEntry, Settings
from app.models import User, Project, TimeEntry, Settings, Invoice
from datetime import datetime
from sqlalchemy import text
import os
@@ -258,6 +258,226 @@ def settings():
return render_template('admin/settings.html', settings=settings_obj)
@admin_bp.route('/admin/pdf-layout', methods=['GET', 'POST'])
@login_required
@admin_required
def pdf_layout():
"""Edit PDF invoice layout template (HTML and CSS)."""
settings_obj = Settings.get_settings()
if request.method == 'POST':
html_template = request.form.get('invoice_pdf_template_html', '')
css_template = request.form.get('invoice_pdf_template_css', '')
settings_obj.invoice_pdf_template_html = html_template
settings_obj.invoice_pdf_template_css = css_template
if not safe_commit('admin_update_pdf_layout'):
from flask_babel import gettext as _
flash(_('Could not update PDF layout due to a database error.'), 'error')
else:
from flask_babel import gettext as _
flash(_('PDF layout updated successfully'), 'success')
return redirect(url_for('admin.pdf_layout'))
# Provide initial defaults to the template if no custom HTML/CSS saved
initial_html = settings_obj.invoice_pdf_template_html or ''
initial_css = settings_obj.invoice_pdf_template_css or ''
try:
if not initial_html:
env = current_app.jinja_env
html_src, _, _ = env.loader.get_source(env, 'invoices/pdf_default.html')
# Extract body only for editor
try:
import re as _re
m = _re.search(r'<body[^>]*>([\s\S]*?)</body>', html_src, _re.IGNORECASE)
initial_html = (m.group(1).strip() if m else html_src)
except Exception:
pass
if not initial_css:
env = current_app.jinja_env
css_src, _, _ = env.loader.get_source(env, 'invoices/pdf_styles_default.css')
initial_css = css_src
except Exception:
pass
return render_template('admin/pdf_layout.html', settings=settings_obj, initial_html=initial_html, initial_css=initial_css)
@admin_bp.route('/admin/pdf-layout/reset', methods=['POST'])
@login_required
@admin_required
def pdf_layout_reset():
"""Reset PDF layout to defaults (clear custom templates)."""
settings_obj = Settings.get_settings()
settings_obj.invoice_pdf_template_html = ''
settings_obj.invoice_pdf_template_css = ''
if not safe_commit('admin_reset_pdf_layout'):
flash(_('Could not reset PDF layout due to a database error.'), 'error')
else:
flash(_('PDF layout reset to defaults'), 'success')
return redirect(url_for('admin.pdf_layout'))
@admin_bp.route('/admin/pdf-layout/default', methods=['GET'])
@login_required
@admin_required
def pdf_layout_default():
"""Return default HTML and CSS template sources for the PDF layout editor."""
try:
env = current_app.jinja_env
# Get raw template sources, not rendered
html_src, _, _ = env.loader.get_source(env, 'invoices/pdf_default.html')
# Extract only the body content for GrapesJS
try:
import re as _re
match = _re.search(r'<body[^>]*>([\s\S]*?)</body>', html_src, _re.IGNORECASE)
if match:
html_src = match.group(1).strip()
except Exception:
pass
except Exception:
html_src = '<div class="wrapper"><h1>{{ _(\'INVOICE\') }} {{ invoice.invoice_number }}</h1></div>'
try:
css_src, _, _ = env.loader.get_source(env, 'invoices/pdf_styles_default.css')
except Exception:
css_src = ''
return jsonify({
'html': html_src,
'css': css_src,
})
@admin_bp.route('/admin/pdf-layout/preview', methods=['POST'])
@login_required
@admin_required
def pdf_layout_preview():
"""Render a live preview of the provided HTML/CSS using an invoice context."""
html = request.form.get('html', '')
css = request.form.get('css', '')
invoice_id = request.form.get('invoice_id', type=int)
invoice = None
if invoice_id:
invoice = Invoice.query.get(invoice_id)
if invoice is None:
invoice = Invoice.query.order_by(Invoice.id.desc()).first()
settings_obj = Settings.get_settings()
# Provide a minimal mock invoice if none exists to avoid template errors
from types import SimpleNamespace
if invoice is None:
from datetime import date
invoice = SimpleNamespace(
invoice_number='0000',
issue_date=date.today(),
due_date=date.today(),
status='draft',
client_name='Sample Client',
client_email='',
client_address='',
project=SimpleNamespace(name='Sample Project', description=''),
items=[],
subtotal=0.0,
tax_rate=0.0,
tax_amount=0.0,
total_amount=0.0,
notes='',
terms='',
)
# Ensure at least one sample item to avoid undefined 'item' in templates that reference it outside loops
sample_item = SimpleNamespace(description='Sample item', quantity=1.0, unit_price=0.0, total_amount=0.0, time_entry_ids='')
try:
if not getattr(invoice, 'items', None):
invoice.items = [sample_item]
except Exception:
try:
invoice.items = [sample_item]
except Exception:
pass
# Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor
def _sanitize_jinja_blocks(raw: str) -> str:
try:
import re as _re
import html as _html
smart_map = {
'\u201c': '"', '\u201d': '"', # “ ” -> "
'\u2018': "'", '\u2019': "'", # -> '
'\u00a0': ' ', # nbsp
'\u200b': '', '\u200c': '', '\u200d': '', # zero-width
}
def _fix_quotes(s: str) -> str:
for k, v in smart_map.items():
s = s.replace(k, v)
return s
def _clean(match):
open_tag = match.group(1)
inner = match.group(2)
# Remove any HTML tags GrapesJS may have inserted inside Jinja braces
inner = _re.sub(r'</?[^>]+?>', '', inner)
# Decode HTML entities
inner = _html.unescape(inner)
# Fix smart quotes and nbsp
inner = _fix_quotes(inner)
# Trim excessive whitespace around pipes and parentheses
inner = _re.sub(r'\s+\|\s+', ' | ', inner)
inner = _re.sub(r'\(\s+', '(', inner)
inner = _re.sub(r'\s+\)', ')', inner)
# Normalize _("...") -> _('...')
inner = inner.replace('_("', "_('").replace('")', "')")
return f"{open_tag}{inner}{' }}' if open_tag == '{{ ' else ' %}'}"
pattern = _re.compile(r'({{\s|{%\s)([\s\S]*?)(?:}}|%})')
return _re.sub(pattern, _clean, raw)
except Exception:
return raw
sanitized = _sanitize_jinja_blocks(html)
# Wrap provided HTML with a minimal page and CSS
try:
from pathlib import Path as _Path
# Provide helpers as callables since templates may use function-style helpers
try:
from babel.dates import format_date as _babel_format_date
except Exception:
_babel_format_date = None
def _format_date(value, format='medium'):
try:
if _babel_format_date:
if format == 'full':
return _babel_format_date(value, format='full')
if format == 'long':
return _babel_format_date(value, format='long')
if format == 'short':
return _babel_format_date(value, format='short')
return _babel_format_date(value, format='medium')
return value.strftime('%Y-%m-%d')
except Exception:
return str(value)
def _format_money(value):
try:
return f"{float(value):,.2f} {settings_obj.currency}"
except Exception:
return f"{value} {settings_obj.currency}"
body_html = render_template_string(
sanitized,
invoice=invoice,
settings=settings_obj,
Path=_Path,
format_date=_format_date,
format_money=_format_money,
item=sample_item,
)
except Exception as e:
body_html = f"<div style='color:red'>Template error: {str(e)}</div>" + sanitized
page_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset='UTF-8'>
<title>PDF Preview</title>
<style>{css}</style>
</head>
<body>{body_html}</body>
</html>
"""
return page_html
@admin_bp.route('/admin/upload-logo', methods=['POST'])
@login_required
@admin_required
+3 -17
View File
@@ -391,51 +391,37 @@ def export_invoice_csv(invoice_id):
def export_invoice_pdf(invoice_id):
"""Export invoice as PDF"""
invoice = Invoice.query.get_or_404(invoice_id)
# Check access permissions
if not current_user.is_admin and invoice.created_by != current_user.id:
flash('You do not have permission to export this invoice', 'error')
flash(_('You do not have permission to export this invoice'), 'error')
return redirect(request.referrer or url_for('invoices.list_invoices'))
try:
from app.utils.pdf_generator import InvoicePDFGenerator
settings = Settings.get_settings()
# Generate PDF (primary: WeasyPrint)
pdf_generator = InvoicePDFGenerator(invoice, settings=settings)
pdf_bytes = pdf_generator.generate_pdf()
filename = f'invoice_{invoice.invoice_number}.pdf'
return send_file(
io.BytesIO(pdf_bytes),
mimetype='application/pdf',
as_attachment=True,
download_name=filename
)
except Exception as e:
# Any failure (including ImportError) -> try ReportLab fallback
try:
from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback
settings = Settings.get_settings()
flash('High-quality generator unavailable; using fallback PDF generator.', 'warning')
flash(_('High-quality generator unavailable; using fallback PDF generator.'), 'warning')
pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings)
pdf_bytes = pdf_generator.generate_pdf()
filename = f'invoice_{invoice.invoice_number}.pdf'
return send_file(
io.BytesIO(pdf_bytes),
mimetype='application/pdf',
as_attachment=True,
download_name=filename
)
except Exception as fallback_error:
flash(f'PDF generation failed: {str(e)}. Fallback also failed: {str(fallback_error)}', 'error')
flash(_('PDF generation failed: %(err)s. Fallback also failed: %(fb)s', err=str(e), fb=str(fallback_error)), 'error')
return redirect(request.referrer or url_for('invoices.view_invoice', invoice_id=invoice.id))
@invoices_bp.route('/invoices/<int:invoice_id>/duplicate')
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -30,6 +30,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}?v={{ app_version }}">
{% block extra_css %}{% endblock %}
{% block head_extra %}{% endblock %}
<script>
(function() {
try {
@@ -373,6 +374,7 @@
</script>
{% endif %}
{% block scripts_extra %}{% endblock %}
{% block extra_js %}{% endblock %}
<script>
// Compact density toggle
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{ _('Invoice') }} {{ invoice.invoice_number }}</title>
</head>
<body>
<div class="wrapper">
<div class="invoice-header">
<div class="brand">
{% if settings.has_logo() %}
{% set logo_path = settings.get_logo_path() %}
{% if logo_path %}
<img src="{{ Path(logo_path).resolve().as_uri() if Path and logo_path else 'file://' ~ logo_path }}" alt="{{ _('Company Logo') }}" class="company-logo">
{% endif %}
{% endif %}
<div>
<h1 class="company-name">{{ settings.company_name|e }}</h1>
<div class="company-meta small">
<span>{{ settings.company_address|e|replace('\n', '<br>')|safe }}</span>
<span>{{ _('Email') }}: {{ settings.company_email|e }} · {{ _('Phone') }}: {{ settings.company_phone|e }}</span>
<span>{{ _('Website') }}: {{ settings.company_website|e }}</span>
{% if settings.company_tax_id %}
<div class="company-tax">{{ _('Tax ID') }}: {{ settings.company_tax_id|e }}</div>
{% endif %}
</div>
</div>
</div>
<div class="invoice-meta">
<div class="invoice-title">{{ _('INVOICE') }}</div>
<div class="meta-grid">
<div class="label">{{ _('Invoice #') }}</div><div class="value">{{ invoice.invoice_number }}</div>
<div class="label">{{ _('Issue Date') }}</div><div class="value">{{ format_date(invoice.issue_date) }}</div>
<div class="label">{{ _('Due Date') }}</div><div class="value">{{ format_date(invoice.due_date) }}</div>
<div class="label">{{ _('Status') }}</div><div class="value">{{ _(invoice.status|title) }}</div>
</div>
</div>
</div>
<div class="two-col">
<div class="card">
<div class="section-title">{{ _('Bill To') }}</div>
<div><strong>{{ invoice.client_name|e }}</strong></div>
{% if invoice.client_email %}
<div class="client-email">{{ invoice.client_email|e }}</div>
{% endif %}
{% if invoice.client_address %}
<div class="client-address">{{ invoice.client_address|e }}</div>
{% endif %}
</div>
<div class="card">
<div class="section-title">{{ _('Project') }}</div>
<div><strong>{{ invoice.project.name|e }}</strong></div>
{% if invoice.project.description %}
<div class="project-description">{{ invoice.project.description|e }}</div>
{% endif %}
</div>
</div>
<div>
<table>
<thead>
<tr>
<th class="desc">{{ _('Description') }}</th>
<th class="num">{{ _('Quantity (Hours)') }}</th>
<th class="num">{{ _('Unit Price') }}</th>
<th class="num">{{ _('Total Amount') }}</th>
</tr>
</thead>
<tbody>
{% for item in invoice.items %}
<tr>
<td>
{{ item.description|e }}
{% if item.time_entry_ids %}
{% set count = item.time_entry_ids.split(',')|length %}
<br><small class="time-entry-info">{{ _('Generated from %(num)d time entries', num=count) }}</small>
{% endif %}
</td>
<td class="num">{{ '%.2f' % item.quantity }}</td>
<td class="num">{{ format_money(item.unit_price) }}</td>
<td class="num">{{ format_money(item.total_amount) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="3" class="num">{{ _('Subtotal:') }}</td>
<td class="num">{{ format_money(invoice.subtotal) }}</td>
</tr>
{% if invoice.tax_rate > 0 %}
<tr>
<td colspan="3" class="num">{{ _('Tax (%(rate).2f%%):', rate=invoice.tax_rate) }}</td>
<td class="num">{{ format_money(invoice.tax_amount) }}</td>
</tr>
{% endif %}
<tr>
<td colspan="3" class="num">{{ _('Total Amount:') }}</td>
<td class="num">{{ format_money(invoice.total_amount) }}</td>
</tr>
</tfoot>
</table>
</div>
{% if invoice.notes or invoice.terms %}
<div class="additional-info">
{% if invoice.notes %}
<div class="notes-section">
<h4>{{ _('Notes:') }}</h4>
<p>{{ invoice.notes|e }}</p>
</div>
{% endif %}
{% if invoice.terms %}
<div class="terms-section">
<h4>{{ _('Terms:') }}</h4>
<p>{{ invoice.terms|e }}</p>
</div>
{% endif %}
</div>
{% endif %}
<div class="footer">
{% if settings.company_bank_info %}
<h4>{{ _('Payment Information:') }}</h4>
<div class="bank-info">{{ settings.company_bank_info|e|replace('\n', '<br>')|safe }}</div>
{% endif %}
<div><strong>{{ _('Terms & Conditions:') }}</strong> {{ settings.invoice_terms|e }}</div>
</div>
</div>
</body>
</html>
+57 -20
View File
@@ -10,7 +10,13 @@ from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from app.models import Settings
from flask import current_app
from flask_babel import gettext as _
try:
from babel.dates import format_date as babel_format_date
except Exception:
babel_format_date = None
from pathlib import Path
from flask import render_template
class InvoicePDFGenerator:
"""Generate PDF invoices with company branding"""
@@ -21,8 +27,13 @@ class InvoicePDFGenerator:
def generate_pdf(self):
"""Generate PDF content and return as bytes"""
html_content = self._generate_html()
css_content = self._generate_css()
use_custom_html = bool((self.settings.invoice_pdf_template_html or '').strip())
use_custom_css = bool((self.settings.invoice_pdf_template_css or '').strip())
if use_custom_html or use_custom_css:
html_content, css_content = self._render_from_custom_template()
else:
html_content = self._generate_html()
css_content = self._generate_css()
# Configure fonts
font_config = FontConfiguration()
@@ -38,6 +49,32 @@ class InvoicePDFGenerator:
pdf_bytes = html_doc.write_pdf(stylesheets=[css_doc], font_config=font_config)
return pdf_bytes
def _render_from_custom_template(self):
"""Render HTML and CSS from custom templates stored in settings, with fallback to default template."""
html_template = (self.settings.invoice_pdf_template_html or '').strip()
css_template = (self.settings.invoice_pdf_template_css or '').strip()
html = ''
if css_template:
css = css_template
else:
try:
from flask import render_template as _render_tpl
css = _render_tpl('invoices/pdf_styles_default.css')
except Exception:
css = self._generate_css()
try:
# Render using Flask's Jinja environment to include app filters and _()
if html_template:
from flask import render_template_string
html = render_template_string(html_template, invoice=self.invoice, settings=self.settings, Path=Path)
except Exception:
html = ''
if not html:
try:
html = render_template('invoices/pdf_default.html', invoice=self.invoice, settings=self.settings, Path=Path)
except Exception:
html = f"<html><body><h1>{_('Invoice')} {self.invoice.invoice_number}</h1></body></html>"
return html, css
def _generate_html(self):
"""Generate HTML content for the invoice"""
@@ -46,7 +83,7 @@ class InvoicePDFGenerator:
<html>
<head>
<meta charset="UTF-8">
<title>Invoice {self.invoice.invoice_number}</title>
<title>{_('Invoice')} {self.invoice.invoice_number}</title>
<style>
:root {{
--primary: #2563eb;
@@ -121,19 +158,19 @@ class InvoicePDFGenerator:
<h1 class="company-name">{self._escape(self.settings.company_name)}</h1>
<div class="company-meta small">
<span>{self._nl2br(self.settings.company_address)}</span>
<span>Email: {self._escape(self.settings.company_email)} · Phone: {self._escape(self.settings.company_phone)}</span>
<span>Website: {self._escape(self.settings.company_website)}</span>
<span>{_('Email')}: {self._escape(self.settings.company_email)} · {_('Phone')}: {self._escape(self.settings.company_phone)}</span>
<span>{_('Website')}: {self._escape(self.settings.company_website)}</span>
{self._get_company_tax_info()}
</div>
</div>
</div>
<div class="invoice-meta">
<div class="invoice-title">INVOICE</div>
<div class="invoice-title">{_('INVOICE')}</div>
<div class="meta-grid">
<div class="label">Invoice #</div><div class="value">{self.invoice.invoice_number}</div>
<div class="label">Issue Date</div><div class="value">{self.invoice.issue_date.strftime('%b %d, %Y')}</div>
<div class="label">Due Date</div><div class="value">{self.invoice.due_date.strftime('%b %d, %Y')}</div>
<div class="label">Status</div><div class="value">{self.invoice.status.title()}</div>
<div class="label">{_('Invoice #')}</div><div class="value">{self.invoice.invoice_number}</div>
<div class="label">{_('Issue Date')}</div><div class="value">{(babel_format_date(self.invoice.issue_date) if babel_format_date else self.invoice.issue_date.strftime('%Y-%m-%d'))}</div>
<div class="label">{_('Due Date')}</div><div class="value">{(babel_format_date(self.invoice.due_date) if babel_format_date else self.invoice.due_date.strftime('%Y-%m-%d'))}</div>
<div class="label">{_('Status')}</div><div class="value">{_(self.invoice.status.title())}</div>
</div>
</div>
</div>
@@ -141,13 +178,13 @@ class InvoicePDFGenerator:
<!-- Client Information -->
<div class="two-col">
<div class="card">
<div class="section-title">Bill To</div>
<div class="section-title">{_('Bill To')}</div>
<div><strong>{self._escape(self.invoice.client_name)}</strong></div>
{self._get_client_email_html()}
{self._get_client_address_html()}
</div>
<div class="card">
<div class="section-title">Project</div>
<div class="section-title">{_('Project')}</div>
<div><strong>{self._escape(self.invoice.project.name)}</strong></div>
{self._get_project_description_html()}
</div>
@@ -158,10 +195,10 @@ class InvoicePDFGenerator:
<table>
<thead>
<tr>
<th class="desc">Description</th>
<th class="num">Quantity (Hours)</th>
<th class="num">Unit Price</th>
<th class="num">Total Amount</th>
<th class="desc">{_('Description')}</th>
<th class="num">{_('Quantity (Hours)')}</th>
<th class="num">{_('Unit Price')}</th>
<th class="num">{_('Total Amount')}</th>
</tr>
</thead>
<tbody>
@@ -179,7 +216,7 @@ class InvoicePDFGenerator:
<!-- Footer -->
<div class="footer">
{self._get_payment_info_html()}
<div><strong>Terms & Conditions:</strong> {self._escape(self.settings.invoice_terms)}</div>
<div><strong>{_('Terms & Conditions:')}</strong> {self._escape(self.settings.invoice_terms)}</div>
</div>
</div>
</body>
@@ -296,7 +333,7 @@ class InvoicePDFGenerator:
if self.invoice.notes:
html_parts.append(f"""
<div class="notes-section">
<h4>Notes:</h4>
<h4>{_('Notes:')}</h4>
<p>{self.invoice.notes}</p>
</div>
""")
@@ -304,7 +341,7 @@ class InvoicePDFGenerator:
if self.invoice.terms:
html_parts.append(f"""
<div class="terms-section">
<h4>Terms:</h4>
<h4>{_('Terms:')}</h4>
<p>{self.invoice.terms}</p>
</div>
""")
@@ -324,7 +361,7 @@ class InvoicePDFGenerator:
"""Generate HTML for payment information"""
if self.settings.company_bank_info:
return f"""
<h4>Payment Information:</h4>
<h4>{_('Payment Information:')}</h4>
<div class="bank-info">{self.settings.company_bank_info}</div>
"""
return ''
+21 -13
View File
@@ -14,6 +14,7 @@ from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER
from reportlab.pdfgen import canvas
from app.models import Settings
from flask import current_app
from flask_babel import gettext as _
class InvoicePDFGeneratorFallback:
"""Generate PDF invoices with company branding using ReportLab"""
@@ -158,10 +159,17 @@ class InvoicePDFGeneratorFallback:
# Fallback to text if image fails
invoice_info.append(Paragraph("[Company Logo]", self.styles['NormalText']))
invoice_info.append(Paragraph("INVOICE", self.styles['InvoiceTitle']))
invoice_info.append(Paragraph(f"Invoice #: {self.invoice.invoice_number}", self.styles['NormalText']))
invoice_info.append(Paragraph(f"Issue Date: {self.invoice.issue_date.strftime('%B %d, %Y')}", self.styles['NormalText']))
invoice_info.append(Paragraph(f"Due Date: {self.invoice.due_date.strftime('%B %d, %Y')}", self.styles['NormalText']))
invoice_info.append(Paragraph(_("INVOICE"), self.styles['InvoiceTitle']))
invoice_info.append(Paragraph(_("Invoice #: %(num)s", num=self.invoice.invoice_number), self.styles['NormalText']))
try:
from babel.dates import format_date as babel_format_date
issue_label = _("Issue Date: %(date)s", date=babel_format_date(self.invoice.issue_date))
due_label = _("Due Date: %(date)s", date=babel_format_date(self.invoice.due_date))
except Exception:
issue_label = _("Issue Date: %(date)s", date=self.invoice.issue_date.strftime('%Y-%m-%d'))
due_label = _("Due Date: %(date)s", date=self.invoice.due_date.strftime('%Y-%m-%d'))
invoice_info.append(Paragraph(issue_label, self.styles['NormalText']))
invoice_info.append(Paragraph(due_label, self.styles['NormalText']))
invoice_info.append(Paragraph(f"Status: {self.invoice.status.title()}", self.styles['NormalText']))
# Create a table to layout company info and invoice info side by side
@@ -205,10 +213,10 @@ class InvoicePDFGeneratorFallback:
"""Build the invoice items table"""
story = []
story.append(Paragraph("Invoice Items", self.styles['SectionHeader']))
story.append(Paragraph(_("Invoice Items"), self.styles['SectionHeader']))
# Table headers
headers = ['Description', 'Quantity (Hours)', 'Unit Price', 'Total Amount']
headers = [_("Description"), _("Quantity (Hours)"), _("Unit Price"), _("Total Amount")]
# Table data
data = [headers]
@@ -222,12 +230,12 @@ class InvoicePDFGeneratorFallback:
data.append(row)
# Add totals
data.append(['', '', 'Subtotal:', self._format_currency(self.invoice.subtotal)])
data.append(['', '', _('Subtotal:'), self._format_currency(self.invoice.subtotal)])
if self.invoice.tax_rate > 0:
data.append(['', '', f'Tax ({self.invoice.tax_rate:.2f}%):', self._format_currency(self.invoice.tax_amount)])
data.append(['', '', _('Tax (%(rate).2f%%):', rate=self.invoice.tax_rate), self._format_currency(self.invoice.tax_amount)])
data.append(['', '', 'Total Amount:', self._format_currency(self.invoice.total_amount)])
data.append(['', '', _('Total Amount:'), self._format_currency(self.invoice.total_amount)])
# Create table
table = Table(data, colWidths=[9*cm, 3*cm, 3*cm, 3*cm], repeatRows=1)
@@ -273,12 +281,12 @@ class InvoicePDFGeneratorFallback:
story = []
if self.invoice.notes:
story.append(Paragraph("Notes:", self.styles['SectionHeader']))
story.append(Paragraph(_("Notes:"), self.styles['SectionHeader']))
story.append(Paragraph(self.invoice.notes, self.styles['NormalText']))
story.append(Spacer(1, 12))
if self.invoice.terms:
story.append(Paragraph("Terms:", self.styles['SectionHeader']))
story.append(Paragraph(_("Terms:"), self.styles['SectionHeader']))
story.append(Paragraph(self.invoice.terms, self.styles['NormalText']))
story.append(Spacer(1, 12))
@@ -290,12 +298,12 @@ class InvoicePDFGeneratorFallback:
# Payment information
if self.settings.company_bank_info:
story.append(Paragraph("Payment Information:", self.styles['SectionHeader']))
story.append(Paragraph(_("Payment Information:"), self.styles['SectionHeader']))
story.append(Paragraph(self.settings.company_bank_info, self.styles['NormalText']))
story.append(Spacer(1, 12))
# Terms and conditions
story.append(Paragraph("Terms & Conditions:", self.styles['SectionHeader']))
story.append(Paragraph(_("Terms & Conditions:"), self.styles['SectionHeader']))
story.append(Paragraph(self.settings.invoice_terms, self.styles['NormalText']))
return story
+34
View File
@@ -7,6 +7,7 @@ except Exception:
_md = None
bleach = None
def register_template_filters(app):
"""Register custom template filters for the application"""
@@ -68,3 +69,36 @@ def register_template_filters(app):
'img': ['src', 'alt', 'title'],
}
return bleach.clean(html, tags=allowed_tags, attributes=allowed_attrs, strip=True)
# Additional filters for PDFs / i18n-friendly formatting
import datetime
try:
from babel.dates import format_date as babel_format_date
except Exception:
babel_format_date = None
@app.template_filter('format_date')
def format_date_filter(value, format='medium'):
if not value:
return ''
if isinstance(value, (datetime.date, datetime.datetime)):
try:
if babel_format_date:
if format == 'full':
return babel_format_date(value, format='full')
if format == 'long':
return babel_format_date(value, format='long')
if format == 'short':
return babel_format_date(value, format='short')
return babel_format_date(value, format='medium')
return value.strftime('%Y-%m-%d')
except Exception:
return value.strftime('%Y-%m-%d')
return str(value)
@app.template_filter('format_money')
def format_money_filter(value):
try:
return f"{float(value):,.2f}"
except Exception:
return str(value)
@@ -0,0 +1,48 @@
"""add pdf template fields to settings
Revision ID: 012
Revises: 011
Create Date: 2025-09-12 00:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '012'
down_revision = '011'
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'settings' not in inspector.get_table_names():
return
columns = {c['name'] for c in inspector.get_columns('settings')}
if 'invoice_pdf_template_html' not in columns:
op.add_column('settings', sa.Column('invoice_pdf_template_html', sa.Text(), nullable=True))
if 'invoice_pdf_template_css' not in columns:
op.add_column('settings', sa.Column('invoice_pdf_template_css', sa.Text(), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'settings' not in inspector.get_table_names():
return
columns = {c['name'] for c in inspector.get_columns('settings')}
if 'invoice_pdf_template_css' in columns:
try:
op.drop_column('settings', 'invoice_pdf_template_css')
except Exception:
pass
if 'invoice_pdf_template_html' in columns:
try:
op.drop_column('settings', 'invoice_pdf_template_html')
except Exception:
pass
+209
View File
@@ -0,0 +1,209 @@
{% extends "base.html" %}
{% block title %}{{ _('PDF Layout') }} - {{ app_name }}{% endblock %}
{% block head_extra %}
<link href="{{ url_for('static', filename='vendor/grapesjs/grapes.min.css') }}" rel="stylesheet">
<style>
#gjs { border: 1px solid #ddd; min-height: 520px; background: #fff; }
#preview-frame { width: 100%; height: 520px; border: 1px solid #ddd; background: #fff; }
.gjs-pn-panel.gjs-pn-views { display: none; }
.editor-actions .btn { margin-right: .5rem; }
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12 d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0"><i class="fas fa-file-pdf text-primary"></i> {{ _('PDF Layout Editor') }}</h1>
<div class="editor-actions">
<button id="btn-load-defaults" class="btn btn-outline-secondary"><i class="fas fa-rotate"></i> {{ _('Load Defaults') }}</button>
<button id="btn-save" class="btn btn-primary"><i class="fas fa-save"></i> {{ _('Save Layout') }}</button>
<form id="form-reset" method="POST" action="{{ url_for('admin.pdf_layout_reset') }}" class="d-inline" onsubmit="return confirm('{{ _('Reset to defaults? This will clear custom HTML and CSS.') }}')">
<button type="submit" class="btn btn-outline-danger"><i class="fas fa-undo"></i> {{ _('Reset') }}</button>
</form>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-7 mb-3">
<div id="gjs"></div>
</div>
<div class="col-lg-5 mb-3">
<iframe id="preview-frame"></iframe>
</div>
</div>
<form id="form-save" method="POST" class="d-none">
<textarea id="input-html" name="invoice_pdf_template_html"></textarea>
<textarea id="input-css" name="invoice_pdf_template_css"></textarea>
</form>
<div class="row">
<div class="col-lg-6">
<div class="card">
<div class="card-header"><strong>{{ _('Notes') }}</strong></div>
<div class="card-body small text-muted">
<p>{{ _('Use the visual editor to drag-and-drop layout. You can switch to code view in the top-right of the editor.') }}</p>
<p>{{ _('Variables (Jinja)') }}:</p>
<ul>
<li><code>{{ '{{ invoice.invoice_number }}' }}</code></li>
<li><code>{{ '{{ format_date(invoice.issue_date) }}' }}</code></li>
<li><code>{{ '{{ format_money(item.total_amount) }}' }}</code></li>
<li><code>{{ '{{ settings.company_name }}' }}</code></li>
<li><code>{{ "{{ _('<Label>') }}" }}</code></li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts_extra %}
<script src="{{ url_for('static', filename='vendor/grapesjs/grapes.min.js') }}"></script>
<script>
// Pass server values for visual editor; keep next script block raw to avoid parsing issues
const __INIT_HTML__ = {{ (initial_html or settings.invoice_pdf_template_html or '')|tojson }};
const __INIT_CSS__ = {{ (initial_css or settings.invoice_pdf_template_css or '')|tojson }};
const __DEFAULTS_URL__ = "{{ url_for('admin.pdf_layout_default') }}";
const __PREVIEW_URL__ = "{{ url_for('admin.pdf_layout_preview') }}";
</script>
{% raw %}
<script>
(function(){
// Fallback to plaintext editor if GrapesJS is unavailable (CDN blocked)
if (!window.grapesjs) {
var g = document.getElementById('gjs');
if (g) {
g.innerHTML = '<div class="mb-2"><label><strong>HTML</strong></label><textarea id="fb-html" class="form-control" rows="14"></textarea></div>' +
'<div class="mb-2"><label><strong>CSS</strong></label><textarea id="fb-css" class="form-control" rows="10"></textarea></div>';
var fbHtml = document.getElementById('fb-html');
var fbCss = document.getElementById('fb-css');
if (typeof __INIT_HTML__ !== 'undefined' && __INIT_HTML__) fbHtml.value = __INIT_HTML__;
if (typeof __INIT_CSS__ !== 'undefined' && __INIT_CSS__) fbCss.value = __INIT_CSS__;
var btnDefaults = document.getElementById('btn-load-defaults');
var btnSave = document.getElementById('btn-save');
var btnPreview = document.getElementById('btn-preview');
if (btnDefaults) btnDefaults.addEventListener('click', function(){
fetch(__DEFAULTS_URL__).then(function(r){return r.json();}).then(function(d){
fbHtml.value = d.html || '';
fbCss.value = d.css || '';
}).catch(function(){});
});
if (btnSave) btnSave.addEventListener('click', function(){
document.getElementById('input-html').value = fbHtml.value;
document.getElementById('input-css').value = fbCss.value;
document.getElementById('form-save').submit();
});
if (btnPreview) btnPreview.addEventListener('click', function(){
var fd = new FormData();
fd.append('html', fbHtml.value);
fd.append('css', fbCss.value);
fetch(__PREVIEW_URL__, { method:'POST', body: fd }).then(function(r){return r.text();}).then(function(html){
var frame = document.getElementById('preview-frame');
var doc = frame.contentDocument || frame.contentWindow.document;
doc.open(); doc.write(html); doc.close();
}).catch(function(){});
});
if ((!__INIT_HTML__ || __INIT_HTML__ === '') && (!__INIT_CSS__ || __INIT_CSS__ === '')) {
if (btnDefaults) btnDefaults.click();
}
}
return;
}
const editor = grapesjs.init({
container: '#gjs',
fromElement: false,
height: '520px',
storageManager: false,
components: __INIT_HTML__ || '<div class="wrapper"><h1>{{ _("INVOICE") }} {{ invoice.invoice_number }}</h1></div>',
style: __INIT_CSS__ || '',
plugins: [],
});
const snippets = {
invoice_header: '<h1>{{ _("INVOICE") }} {{ invoice.invoice_number }}</h1>',
items_table: '<table style="width:100%;border-collapse:collapse"><thead><tr><th>{{ _("Description") }}</th><th style="text-align:right">{{ _("Quantity (Hours)") }}</th><th style="text-align:right">{{ _("Unit Price") }}</th><th style="text-align:right">{{ _("Total Amount") }}</th></tr></thead><tbody>{% for item in invoice.items %}<tr><td>{{ item.description }}</td><td style="text-align:right">{{ "%.2f"|format(item.quantity) }}</td><td style="text-align:right">{{ format_money(item.unit_price) }}</td><td style="text-align:right">{{ format_money(item.total_amount) }}</td></tr>{% endfor %}</tbody></table>',
subtotal_row: '<div><strong>{{ _("Subtotal:") }}</strong> {{ format_money(invoice.subtotal) }}</div>',
terms: '<div><strong>{{ _("Terms & Conditions:") }}</strong> {{ settings.invoice_terms }}</div>'
};
function loadDefaults(){
fetch(__DEFAULTS_URL__).then(r=>r.json()).then(data=>{
editor.setComponents(data.html || '');
editor.setStyle(data.css || '');
renderPreview();
});
}
function getHtmlCss(){
const html = editor.getHtml();
const css = editor.getCss();
return { html, css };
}
function renderPreview(){
const { html, css } = getHtmlCss();
const formData = new FormData();
formData.append('html', html);
formData.append('css', css);
fetch(__PREVIEW_URL__, { method:'POST', body: formData })
.then(r=>r.text()).then(html => {
const frame = document.getElementById('preview-frame');
const doc = frame.contentDocument || frame.contentWindow.document;
doc.open();
doc.write(html);
doc.close();
});
}
document.getElementById('btn-preview').addEventListener('click', function(){ renderPreview(); });
document.getElementById('btn-load-defaults').addEventListener('click', function(){ loadDefaults(); });
document.getElementById('btn-save').addEventListener('click', function(){
const { html, css } = getHtmlCss();
document.getElementById('input-html').value = html;
document.getElementById('input-css').value = css;
document.getElementById('form-save').submit();
});
document.querySelectorAll('[data-key]').forEach(btn=>{
btn.addEventListener('click', function(e){
e.preventDefault();
const comp = editor.getSelected() || editor.getWrapper();
const key = this.getAttribute('data-key');
const tpl = snippets[key] || '';
if (!tpl) return;
comp.append(tpl);
renderPreview();
});
});
document.getElementById('btn-insert-vars').addEventListener('click', function(){
const menu = `
<div class="p-2">
<div class="mb-1"><code>{{ invoice.invoice_number }}</code></div>
<div class="mb-1"><code>{{ format_date(invoice.issue_date) }}</code></div>
<div class="mb-1"><code>{{ settings.company_name }}</code></div>
<div class="mb-1"><code>{{ _("Label") }}</code></div>
</div>`;
alert(menu);
});
if (!__INIT_HTML__ && !__INIT_CSS__) {
loadDefaults();
} else {
renderPreview();
}
})();
</script>
{% endraw %}
{% endblock %}
+6 -1
View File
@@ -21,7 +21,12 @@
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-cog me-1"></i> {{ _('Configuration') }}</h5>
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-cog me-1"></i> {{ _('Configuration') }}</h5>
<a href="{{ url_for('admin.pdf_layout') }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-file-pdf"></i> {{ _('Edit PDF Layout') }}
</a>
</div>
</div>
<div class="card-body">
<form method="POST">
+62
View File
@@ -0,0 +1,62 @@
@page {
size: A4;
margin: 2cm;
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
font-size: 10pt;
color: #666;
}
}
:root {
--primary: #2563eb;
--primary-600: #1d4ed8;
--text: #0f172a;
--muted: #475569;
--border: #e2e8f0;
--bg: #ffffff;
--bg-alt: #f8fafc;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: var(--text);
margin: 0; padding: 0; background: var(--bg); font-size: 12pt;
}
.wrapper { padding: 24px 28px; }
.invoice-header { display:flex; align-items:flex-start; justify-content:space-between; border-bottom:2px solid var(--border); padding-bottom:16px; margin-bottom:18px; }
.brand { display:flex; gap:16px; align-items:center; }
.company-logo { max-width:140px; max-height:70px; display:block; }
.company-name { font-size:22pt; font-weight:700; margin:0; color:var(--primary); }
.company-meta span { display:block; color:var(--muted); font-size:10pt; }
.invoice-meta { text-align:right; }
.invoice-title { font-size:26pt; font-weight:800; color:var(--primary); margin:0 0 8px 0; }
.meta-grid { display:grid; grid-template-columns:auto auto; gap:4px 16px; font-size:10.5pt; }
.label { color:var(--muted); font-weight:600; }
.value { color:var(--text); font-weight:600; }
.two-col { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:18px; }
.card { background:var(--bg-alt); border:1px solid var(--border); border-radius:8px; padding:12px 14px; }
.section-title { font-size:12pt; font-weight:700; color:var(--primary-600); margin:0 0 8px 0; }
.small { color:var(--muted); font-size:10pt; }
table { width:100%; border-collapse:collapse; margin-top:4px; }
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
thead th { background:var(--bg-alt); color:var(--muted); font-weight:700; border:1px solid var(--border); padding:10px; font-size:10.5pt; text-align:left; }
tbody td { border:1px solid var(--border); padding:10px; font-size:10.5pt; }
tfoot td { border:1px solid var(--border); padding:10px; font-weight:700; }
.num { text-align:right; }
.desc { width:50%; }
tr, td, th { break-inside: avoid; page-break-inside: avoid; }
.card, .invoice-header, .two-col { break-inside: avoid; page-break-inside: avoid; }
h4 { break-after: avoid; }
.totals { margin-top:6px; }
.note { margin-top:10px; }
.footer { border-top:1px solid var(--border); margin-top:18px; padding-top:10px; color:var(--muted); font-size:10pt; }
.time-entry-info { color:#6c757d; font-style:italic; }