Files
TimeTracker/app/routes/admin.py
T
Dries Peeters 786d88bdba style: apply black 24.8.0 and isort across app/
Pure formatting pass to satisfy ``./scripts/run-ci-local.sh code-quality``:
no behavioural changes, just consistent line wrapping, import ordering,
and trailing-newline normalization across routes, models, services, and
utility modules.
2026-05-13 10:31:39 +02:00

5537 lines
231 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import shutil
import threading
import time
import uuid
from datetime import datetime
from flask import (
Blueprint,
current_app,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
send_from_directory,
url_for,
)
from flask_babel import gettext as _
from flask_login import current_user, login_required
from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError
from werkzeug.utils import secure_filename
import app as app_module
from app import db, limiter
from app.config.analytics_defaults import get_analytics_config
from app.models import (
DonationInteraction,
Invoice,
Project,
Quote,
QuoteItem,
Role,
Settings,
TimeEntry,
User,
UserClient,
)
from app.utils.auth_method import auth_includes_ldap, auth_includes_oidc, normalize_auth_method
from app.utils.backup import create_backup, get_backup_root_dir, restore_backup
from app.utils.db import safe_commit
from app.utils.error_handling import safe_file_remove, safe_log
from app.utils.installation import get_installation_config
from app.utils.invoice_numbering import sanitize_invoice_pattern, sanitize_invoice_prefix, validate_invoice_pattern
from app.utils.permissions import admin_or_permission_required
from app.utils.safe_template_render import render_sandboxed_string
from app.utils.telemetry import get_telemetry_fingerprint, is_telemetry_enabled
from app.utils.timezone import get_available_timezones
admin_bp = Blueprint("admin", __name__)
def _ldap_admin_display():
"""Read-only LDAP config summary for admin settings (from env / app config)."""
try:
cfg = current_app.config
ag = (cfg.get("LDAP_ADMIN_GROUP") or "").strip()
rg = (cfg.get("LDAP_REQUIRED_GROUP") or "").strip()
return {
"enabled": bool(cfg.get("LDAP_ENABLED")),
"host": cfg.get("LDAP_HOST") or "",
"port": int(cfg.get("LDAP_PORT") or 389),
"use_ssl": bool(cfg.get("LDAP_USE_SSL")),
"use_tls": bool(cfg.get("LDAP_USE_TLS")),
"base_dn": cfg.get("LDAP_BASE_DN") or "",
"user_dn": cfg.get("LDAP_USER_DN") or "",
"login_attr": cfg.get("LDAP_USER_LOGIN_ATTR") or "",
"admin_group": ag or "",
"required_group": rg or "",
}
except Exception:
return {
"enabled": False,
"host": "",
"port": 389,
"use_ssl": False,
"use_tls": False,
"base_dn": "",
"user_dn": "",
"login_attr": "",
"admin_group": "",
"required_group": "",
}
@admin_bp.context_processor
def _inject_ldap_admin_display():
return {"ldap_settings": _ldap_admin_display()}
# In-memory restore progress tracking (simple, per-process)
RESTORE_PROGRESS = {}
# Allowed file extensions for logos
# Avoid SVG due to XSS risk unless sanitized server-side
ALLOWED_LOGO_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
def _convert_json_template_to_html_css(template_json, page_size="A4", invoice=None, quote=None, settings=None):
"""
Convert JSON template to HTML/CSS for preview purposes with full element type support.
Args:
template_json: Dictionary containing template definition
page_size: Page size for CSS @page rule
invoice: Optional invoice object for table data rendering
quote: Optional quote object for table data rendering
settings: Optional settings object for company information
Returns:
tuple: (html_string, css_string)
"""
import html as html_escape
import json as json_module
from app.utils.pdf_template_schema import get_page_dimensions_points
# Get page dimensions
dims = get_page_dimensions_points(page_size)
width_pt = dims["width"]
height_pt = dims["height"]
# Convert points to pixels at 96 DPI for browser (72 DPI * 96/72 = 1.333)
# But for accuracy, use 1pt = 1.333px conversion for browser
width_px = int(width_pt * 96 / 72)
height_px = int(height_pt * 96 / 72)
# Font mapping: ReportLab fonts to web fonts
font_map = {
"Helvetica": "Arial, Helvetica, sans-serif",
"Helvetica-Bold": "Arial, Helvetica, sans-serif",
"Helvetica-Oblique": "Arial, Helvetica, sans-serif",
"Helvetica-BoldOblique": "Arial, Helvetica, sans-serif",
"Times-Roman": "Times New Roman, Times, serif",
"Times-Bold": "Times New Roman, Times, serif",
"Times-Italic": "Times New Roman, Times, serif",
"Times-BoldItalic": "Times New Roman, Times, serif",
"Courier": "Courier New, Courier, monospace",
"Courier-Bold": "Courier New, Courier, monospace",
"Courier-Oblique": "Courier New, Courier, monospace",
"Courier-BoldOblique": "Courier New, Courier, monospace",
}
# Get page margins from template
page_config = template_json.get("page", {})
margin_top = page_config.get("margin", {}).get("top", 20)
margin_bottom = page_config.get("margin", {}).get("bottom", 20)
margin_left = page_config.get("margin", {}).get("left", 20)
margin_right = page_config.get("margin", {}).get("right", 20)
# Build CSS with @page rule and comprehensive styles
css = f"""@page {{
size: {page_size};
margin: {margin_top}mm {margin_right}mm {margin_bottom}mm {margin_left}mm;
}}
* {{
box-sizing: border-box;
}}
body {{
width: {width_px}px;
height: {height_px}px;
margin: 0;
padding: 0;
font-family: Arial, Helvetica, sans-serif;
font-size: 10pt;
background: white;
overflow: hidden;
}}
.invoice-wrapper,
.quote-wrapper {{
width: {width_px}px;
height: {height_px}px;
position: relative;
background: white;
margin: 0;
padding: 0;
}}
.element {{
position: absolute;
box-sizing: border-box;
}}
.text-element {{
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
margin: 0;
padding: 0;
}}
.image-element {{
object-fit: contain;
display: block;
}}
.rectangle-element {{
box-sizing: border-box;
}}
.circle-element {{
border-radius: 50%;
box-sizing: border-box;
}}
.line-element {{
transform-origin: left center;
}}
.table-element {{
border-collapse: collapse;
border-spacing: 0;
table-layout: auto;
margin: 0;
box-sizing: border-box;
}}
.table-element th,
.table-element td {{
padding: 8px 12px;
border: 1px solid #ddd;
text-align: left;
vertical-align: top;
box-sizing: border-box;
}}
.table-element th {{
background-color: #f8f9fa;
font-weight: bold;
border-bottom: 2px solid #333;
}}
.table-element tbody tr:nth-child(even) {{
background-color: #f9f9f9;
}}
.table-element tbody tr:hover {{
background-color: #f0f0f0;
}}
"""
# Helper function to map ReportLab fonts to web fonts
def get_font_family(font_name):
if not font_name:
return "Arial, Helvetica, sans-serif"
return font_map.get(font_name, font_map.get(font_name.split("-")[0], "Arial, Helvetica, sans-serif"))
# Helper function to get font weight from font name
def get_font_weight(font_name):
if not font_name:
return "normal"
if "Bold" in font_name:
return "bold"
return "normal"
# Helper function to get font style from font name
def get_font_style(font_name):
if not font_name:
return "normal"
if "Oblique" in font_name or "Italic" in font_name:
return "italic"
return "normal"
# Helper function to convert color (supports hex, rgb, named colors)
def format_color(color):
if not color:
return "#000000"
# If already hex or named color, return as-is
if color.startswith("#") or color in ["black", "white", "red", "blue", "green", "yellow", "cyan", "magenta"]:
return color
# If RGB tuple/list, convert to hex
if isinstance(color, (list, tuple)) and len(color) >= 3:
return f"#{int(color[0]):02x}{int(color[1]):02x}{int(color[2]):02x}"
return str(color)
# Helper function to render text with Jinja2-like template variables
def render_text_template(text, data_obj, settings_obj=None):
"""Render text with template variables using actual data"""
if not text:
return text
# Simple template variable replacement for preview
# Replace {{ variable }} patterns with actual values
import re
def replace_var(match):
var_path = match.group(1).strip()
try:
# Handle simple attribute access (e.g., "invoice.invoice_number" or "settings.company_name")
parts = var_path.split(".")
if parts[0] == "settings" and settings_obj:
# Handle settings variables
obj = settings_obj
for part in parts[1:]: # Skip "settings" part
obj = getattr(obj, part, None)
if obj is None:
return match.group(0) # Return original if not found
return str(obj) if obj is not None else ""
elif data_obj:
# Handle invoice/quote variables
obj = data_obj
for part in parts:
obj = getattr(obj, part, None)
if obj is None:
return match.group(0) # Return original if not found
return str(obj) if obj is not None else ""
else:
return match.group(0) # Return original if no data object
except Exception:
return match.group(0) # Return original on error
return re.sub(r"\{\{\s*([^}]+)\s*\}\}", replace_var, text)
# Build HTML from elements
html_parts = ['<div class="invoice-wrapper">'] if invoice else ['<div class="quote-wrapper">']
elements = template_json.get("elements", [])
for idx, element in enumerate(elements):
elem_type = element.get("type", "")
x = element.get("x", 0)
y = element.get("y", 0)
style = element.get("style", {})
opacity = style.get("opacity", 1.0)
rotation = element.get("rotation", 0)
# Convert points to pixels at 96 DPI for browser
x_px = int(x * 96 / 72)
y_px = int(y * 96 / 72)
# Base style string
base_style_parts = [
f"left: {x_px}px",
f"top: {y_px}px",
f"opacity: {opacity}",
]
if rotation:
base_style_parts.append(f"transform: rotate({rotation}deg)")
base_style_parts.append("transform-origin: top left")
style_str_base = "; ".join(base_style_parts)
if elem_type == "text":
text = element.get("text", "")
width = element.get("width", 400)
height = element.get("height", None)
width_px_elem = int(width * 96 / 72)
font_name = style.get("font", "Helvetica")
font_size = style.get("size", 10)
color = format_color(style.get("color", "#000000"))
align = style.get("align", "left")
valign = style.get("valign", "top")
# Build complete style string
style_parts = [style_str_base]
style_parts.append(f"width: {width_px_elem}px")
if height:
style_parts.append(f"height: {int(height * 96 / 72)}px")
style_parts.append(f"font-family: {get_font_family(font_name)}")
style_parts.append(f"font-size: {font_size}pt")
style_parts.append(f"font-weight: {get_font_weight(font_name)}")
style_parts.append(f"font-style: {get_font_style(font_name)}")
style_parts.append(f"color: {color}")
style_parts.append(f"text-align: {align}")
style_parts.append(f"vertical-align: {valign}")
style_str = "; ".join(style_parts) + ";"
# Render text with actual data if available
data_obj = invoice if invoice else quote
rendered_text = render_text_template(text, data_obj, settings) if (data_obj or settings) else text
# Escape HTML but preserve any remaining template syntax
text_escaped = html_escape.escape(rendered_text)
# Restore template syntax if any remains (shouldn't after rendering, but just in case)
text_escaped = text_escaped.replace("&lt;{{", "{{").replace("}}&gt;", "}}")
html_parts.append(f'<div class="element text-element" style="{style_str}">{text_escaped}</div>')
elif elem_type == "image":
width = element.get("width", 100)
height = element.get("height", 100)
width_px_elem = int(width * 96 / 72)
height_px_elem = int(height * 96 / 72)
source = element.get("source", "")
is_decorative = element.get("decorative", False)
# Handle base64 data URLs or file paths
img_src = ""
if source.startswith("data:"):
img_src = source
elif source.startswith("/uploads/template_images/"):
# Template image - convert to base64 for PDF generation
try:
from app.utils.template_filters import get_image_base64
# Extract filename from URL
filename = source.split("/uploads/template_images/")[-1]
# Build file path relative to app root (as get_image_base64 expects)
relative_path = f"app/static/uploads/template_images/{filename}"
# Convert to base64
img_src = get_image_base64(relative_path) or source # Fallback to URL if conversion fails
except Exception as e:
# If conversion fails, use the URL directly
current_app.logger.warning(f"Failed to convert template image to base64: {e}")
img_src = source
elif source.startswith("/") or source.startswith("http"):
img_src = source
else:
# Assume it's a relative path or placeholder
if source:
img_src = source
else:
# Placeholder for decorative images without source
img_src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%23ddd' width='100' height='100'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23999'%3EImage%3C/text%3E%3C/svg%3E"
style_parts = [style_str_base]
style_parts.append(f"width: {width_px_elem}px")
style_parts.append(f"height: {height_px_elem}px")
style_str = "; ".join(style_parts) + ";"
if img_src and not img_src.startswith("data:image/svg+xml"):
html_parts.append(
f'<img class="element image-element" src="{img_src}" style="{style_str}" alt="Decorative image">'
)
else:
# Show placeholder for decorative images without source
html_parts.append(
f'<div class="element" style="{style_str}background:#f0f0f0;border:2px dashed #999;display:flex;align-items:center;justify-content:center;color:#666;font-size:12px;">Decorative Image</div>'
)
elif elem_type == "rectangle":
width = element.get("width", 100)
height = element.get("height", 100)
width_px_elem = int(width * 96 / 72)
height_px_elem = int(height * 96 / 72)
fill = format_color(style.get("fill", "#ffffff"))
stroke = format_color(style.get("stroke", "#000000"))
stroke_width = style.get("strokeWidth", 1)
style_parts = [style_str_base]
style_parts.append(f"width: {width_px_elem}px")
style_parts.append(f"height: {height_px_elem}px")
style_parts.append(f"background-color: {fill}")
if stroke_width > 0:
style_parts.append(f"border: {stroke_width}px solid {stroke}")
style_str = "; ".join(style_parts) + ";"
html_parts.append(f'<div class="element rectangle-element" style="{style_str}"></div>')
elif elem_type == "circle":
radius = element.get("radius", 50)
radius_px = int(radius * 96 / 72)
fill = format_color(style.get("fill", "#ffffff"))
stroke = format_color(style.get("stroke", "#000000"))
stroke_width = style.get("strokeWidth", 1)
style_parts = [style_str_base]
style_parts.append(f"width: {radius_px * 2}px")
style_parts.append(f"height: {radius_px * 2}px")
style_parts.append(f"background-color: {fill}")
if stroke_width > 0:
style_parts.append(f"border: {stroke_width}px solid {stroke}")
style_str = "; ".join(style_parts) + ";"
html_parts.append(f'<div class="element circle-element" style="{style_str}"></div>')
elif elem_type == "line":
width = element.get("width", 100)
height = element.get("height", 0)
# For lines, width is the length, height is the stroke width (thickness)
width_px_elem = int(width * 96 / 72)
stroke_width = height if height > 0 else style.get("strokeWidth", 1)
stroke_width_px = max(1, int(stroke_width * 96 / 72))
stroke = format_color(style.get("stroke", "#000000"))
style_parts = [style_str_base]
style_parts.append(f"width: {width_px_elem}px")
style_parts.append(f"height: {stroke_width_px}px")
style_parts.append(f"background-color: {stroke}")
style_str = "; ".join(style_parts) + ";"
html_parts.append(f'<div class="element line-element" style="{style_str}"></div>')
elif elem_type == "table":
width = element.get("width", 500)
width_px_elem = int(width * 96 / 72)
columns = element.get("columns", [])
row_template = element.get("row_template", {})
# Get table style properties
table_style = element.get("style", {})
border_color = format_color(table_style.get("borderColor", "#000000"))
header_bg = format_color(table_style.get("headerBackground", "#f8f9fa"))
style_parts = [style_str_base]
style_parts.append(f"width: {width_px_elem}px")
style_parts.append(f"border: 1px solid {border_color}")
style_str = "; ".join(style_parts) + ";"
table_html = f'<table class="element table-element" style="{style_str}"><thead><tr>'
# Build header row
for col in columns:
header = col.get("header", "")
align = col.get("align", "left")
col_width = col.get("width", None)
width_attr = f' width="{int(col_width * 96 / 72)}px"' if col_width else ""
table_html += f'<th style="text-align: {align}; background-color: {header_bg};"{width_attr}>{html_escape.escape(header)}</th>'
table_html += "</tr></thead><tbody>"
# Resolve table data from element's data source (e.g. invoice.all_line_items or invoice.items)
data_obj = invoice if invoice else quote
items = []
data_source = element.get("data", "").strip()
var_name = data_source.replace("{{", "").replace("}}", "").strip() if data_source else ""
if data_obj and var_name:
try:
parts = var_name.split(".")
if len(parts) >= 2 and parts[0] in ("invoice", "quote"):
resolved = data_obj
for part in parts[1:]:
resolved = getattr(resolved, part, None) if resolved is not None else None
if resolved is None:
break
if resolved is not None:
if hasattr(resolved, "all"):
items = list(resolved.all())
elif hasattr(resolved, "__iter__") and not isinstance(resolved, (str, bytes)):
items = list(resolved)
else:
items = [resolved]
except Exception as e:
safe_log(current_app.logger, "warning", "Dashboard data resolution failed: %s", e)
# Fallback: use data_obj.items (e.g. when data source not set or resolution failed)
if not items and data_obj and hasattr(data_obj, "items"):
try:
if hasattr(data_obj.items, "all"):
items = data_obj.items.all()
elif isinstance(data_obj.items, list):
items = data_obj.items
else:
items = list(data_obj.items) if data_obj.items else []
except Exception as e:
safe_log(current_app.logger, "debug", "Dashboard data fallback items failed: %s", e)
items = []
# If no items available, create sample row from template
if not items and row_template:
items = [row_template] # Use template as sample data
# Render table rows with actual data
if items:
for item in items[:10]: # Limit to 10 rows for preview
table_html += "<tr>"
for col in columns:
field = col.get("field", "")
align = col.get("align", "left")
# Try to get value from item
value = ""
try:
if hasattr(item, field):
value = str(getattr(item, field, ""))
elif isinstance(item, dict):
value = str(item.get(field, ""))
else:
value = ""
except Exception as e:
safe_log(current_app.logger, "debug", "Template value for field %s failed: %s", field, e)
value = ""
value_escaped = html_escape.escape(str(value))
table_html += f'<td style="text-align: {align};">{value_escaped}</td>'
table_html += "</tr>"
else:
# No data available, show template placeholders
table_html += "<tr>"
for col in columns:
field = col.get("field", "")
align = col.get("align", "left")
placeholder = f"{{{{ {field} }}}}"
table_html += (
f'<td style="text-align: {align}; color: #999;">{html_escape.escape(placeholder)}</td>'
)
table_html += "</tr>"
table_html += "</tbody></table>"
html_parts.append(table_html)
html_parts.append("</div>")
html = "\n".join(html_parts)
return html, css
def admin_required(f):
"""Decorator to require admin access
DEPRECATED: Use @admin_or_permission_required() with specific permissions instead.
This decorator is kept for backward compatibility.
"""
from functools import wraps
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
flash(_("Administrator access required"), "error")
return redirect(url_for("main.dashboard"))
return f(*args, **kwargs)
return decorated_function
def allowed_logo_file(filename):
"""Check if the uploaded file has an allowed extension"""
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_LOGO_EXTENSIONS
def get_upload_folder():
"""Get the upload folder path for logos"""
upload_folder = os.path.join(current_app.root_path, "static", "uploads", "logos")
try:
os.makedirs(upload_folder, exist_ok=True)
current_app.logger.info(f"Logo upload folder ensured: {upload_folder}")
except Exception as e:
current_app.logger.error(f"Error creating upload folder {upload_folder}: {str(e)}")
raise
return upload_folder
@admin_bp.route("/admin")
@login_required
@admin_or_permission_required("access_admin")
def admin_dashboard():
"""Admin dashboard"""
from datetime import datetime, timedelta
from sqlalchemy import case, func
from app.config import Config
# Get system statistics
total_users = User.query.count()
active_users = User.query.filter_by(is_active=True).count()
total_projects = Project.query.count()
active_projects = Project.query.filter_by(status="active").count()
total_entries = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)).count()
active_timers = TimeEntry.query.filter_by(end_time=None).count()
# Get recent activity
recent_entries = (
TimeEntry.query.filter(TimeEntry.end_time.isnot(None)).order_by(TimeEntry.created_at.desc()).limit(10).all()
)
# Get OIDC status
auth_method = normalize_auth_method(getattr(Config, "AUTH_METHOD", "local"))
oidc_enabled = auth_includes_oidc(auth_method)
oidc_issuer = getattr(Config, "OIDC_ISSUER", None)
oidc_configured = (
oidc_enabled
and oidc_issuer
and getattr(Config, "OIDC_CLIENT_ID", None)
and getattr(Config, "OIDC_CLIENT_SECRET", None)
)
# Count OIDC users
oidc_users_count = 0
try:
oidc_users_count = User.query.filter(User.oidc_issuer.isnot(None), User.oidc_sub.isnot(None)).count()
except Exception as e:
# Log error but continue - OIDC user count is not critical for dashboard display
current_app.logger.warning(f"Failed to count OIDC users: {e}", exc_info=True)
# Chart data for last 30 days (cached 10 min to reduce DB load)
from app.utils.cache import get_cache
_cache = get_cache()
chart_data = _cache.get("admin:dashboard:chart")
if chart_data is None:
from datetime import date as date_type
end_date = datetime.utcnow()
range_start = (end_date - timedelta(days=29)).replace(hour=0, minute=0, second=0, microsecond=0)
range_end = end_date
all_dates = [(end_date - timedelta(days=i)).date() for i in range(29, -1, -1)]
def _norm_date(v):
if v is None:
return None
if isinstance(v, date_type):
return v
if hasattr(v, "date") and callable(getattr(v, "date")):
return v.date()
if isinstance(v, str):
try:
return date_type.fromisoformat(v[:10])
except (ValueError, TypeError):
return v
return v
user_activity_rows = (
db.session.query(
func.date(TimeEntry.start_time).label("day"),
func.count(func.distinct(TimeEntry.user_id)).label("cnt"),
)
.filter(
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= range_start,
TimeEntry.start_time <= range_end,
)
.group_by(func.date(TimeEntry.start_time))
.all()
)
user_activity_by_date = {_norm_date(d.day): d.cnt for d in user_activity_rows}
user_activity_data = [
{"date": d.strftime("%Y-%m-%d"), "count": user_activity_by_date.get(d, 0)} for d in all_dates
]
project_status_data = {}
status_counts = db.session.query(Project.status, func.count(Project.id)).group_by(Project.status).all()
for status, count in status_counts:
project_status_data[status or "none"] = count
time_hours_rows = (
db.session.query(
func.date(TimeEntry.start_time).label("day"),
func.sum(TimeEntry.duration_seconds).label("total_seconds"),
)
.filter(
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= range_start,
TimeEntry.start_time <= range_end,
)
.group_by(func.date(TimeEntry.start_time))
.all()
)
time_hours_by_date = {}
for row in time_hours_rows:
day = _norm_date(row.day)
if day is not None:
time_hours_by_date[day] = round((row.total_seconds or 0) / 3600, 2)
time_entries_daily = [
{"date": d.strftime("%Y-%m-%d"), "hours": time_hours_by_date.get(d, 0)} for d in all_dates
]
chart_data = {
"user_activity": user_activity_data,
"project_status": project_status_data,
"time_entries_daily": time_entries_daily,
}
try:
_cache.set("admin:dashboard:chart", chart_data, ttl=600)
except Exception as e:
safe_log(current_app.logger, "debug", "Admin dashboard chart cache set failed: %s", e)
# Build stats object expected by the template
stats = {
"total_users": total_users,
"active_users": active_users,
"total_projects": total_projects,
"active_projects": active_projects,
"total_entries": total_entries,
"active_timers": active_timers,
"total_hours": TimeEntry.get_total_hours_for_period(),
"billable_hours": TimeEntry.get_total_hours_for_period(billable_only=True),
"last_backup": None,
}
return render_template(
"admin/dashboard.html",
stats=stats,
active_timers=active_timers,
recent_entries=recent_entries,
oidc_enabled=oidc_enabled,
oidc_configured=oidc_configured,
oidc_auth_method=auth_method,
oidc_users_count=oidc_users_count,
chart_data=chart_data,
)
# Compatibility alias for code/templates that might reference 'admin.dashboard'
@admin_bp.route("/admin/dashboard")
@login_required
@admin_or_permission_required("access_admin")
def admin_dashboard_alias():
"""Alias endpoint so url_for('admin.dashboard') remains valid.
Some older references may use the endpoint name 'admin.dashboard'.
Redirect to the canonical admin dashboard endpoint.
"""
return redirect(url_for("admin.admin_dashboard"))
@admin_bp.route("/admin/users")
@login_required
@admin_or_permission_required("view_users")
def list_users():
"""List all users"""
users = User.query.order_by(User.username).all()
# Build stats for users page
stats = {
"total_users": User.query.count(),
"active_users": User.query.filter_by(is_active=True).count(),
"admin_users": User.query.filter_by(role="admin").count(),
"total_hours": TimeEntry.get_total_hours_for_period(),
}
return render_template("admin/users.html", users=users, stats=stats)
@admin_bp.route("/admin/users/create", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("create_users")
def create_user():
"""Create a new user"""
if request.method == "POST":
if current_app.config.get("DEMO_MODE"):
flash(_("User creation is disabled in demo mode."), "error")
return redirect(url_for("admin.list_users"))
username = request.form.get("username", "").strip().lower()
role_name = request.form.get("role", "user") # This will be a role name from the Role system
default_password = request.form.get("default_password", "").strip()
force_password_change = request.form.get("force_password_change") == "on"
if not username:
flash(_("Username is required"), "error")
all_roles = Role.query.order_by(Role.name).all()
return render_template("admin/user_form.html", user=None, all_roles=all_roles)
# Check if user already exists
if User.query.filter_by(username=username).first():
flash(_("User already exists"), "error")
all_roles = Role.query.order_by(Role.name).all()
return render_template("admin/user_form.html", user=None, all_roles=all_roles)
# Get the Role object from the database
role_obj = Role.query.filter_by(name=role_name).first()
if not role_obj:
# Fallback: if role doesn't exist, try to use "user" role
role_obj = Role.query.filter_by(name="user").first()
if not role_obj:
flash(_("Default 'user' role not found. Please run 'flask seed_permissions_cmd' first."), "error")
all_roles = Role.query.order_by(Role.name).all()
return render_template("admin/user_form.html", user=None, all_roles=all_roles)
# Create user with legacy role field for backward compatibility
user = User(username=username, role=role_name)
# Apply company default for daily working hours (overtime)
try:
settings = Settings.get_settings()
user.standard_hours_per_day = float(getattr(settings, "default_daily_working_hours", 8.0) or 8.0)
except Exception as e:
safe_log(current_app.logger, "debug", "Default daily working hours for new user failed: %s", e)
# Assign the role from the new Role system
user.roles.append(role_obj)
# Set default password if provided
if default_password:
user.set_password(default_password)
if force_password_change:
user.password_change_required = True
db.session.add(user)
if not safe_commit("admin_create_user", {"username": username}):
flash(_("Could not create user due to a database error. Please check server logs."), "error")
all_roles = Role.query.order_by(Role.name).all()
return render_template("admin/user_form.html", user=None, all_roles=all_roles)
flash(_('User "%(username)s" created successfully', username=username), "success")
return redirect(url_for("admin.list_users"))
# GET request - show form with available roles
all_roles = Role.query.order_by(Role.name).all()
return render_template("admin/user_form.html", user=None, all_roles=all_roles)
@admin_bp.route("/admin/users/<int:user_id>/edit", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("edit_users")
def edit_user(user_id):
"""Edit an existing user"""
from app.models import Client
user = User.query.get_or_404(user_id)
clients = Client.query.filter_by(status="active").order_by(Client.name).all()
all_roles = Role.query.order_by(Role.name).all()
assigned_client_ids = [c.id for c in user.assigned_clients.all()]
if request.method == "POST":
username = request.form.get("username", "").strip().lower()
role_name = request.form.get("role", "user") # This will be a role name from the Role system
is_active = request.form.get("is_active") == "on"
client_portal_enabled = request.form.get("client_portal_enabled") == "on"
client_id = request.form.get("client_id", "").strip()
if not username:
flash(_("Username is required"), "error")
return render_template(
"admin/user_form.html",
user=user,
clients=clients,
all_roles=all_roles,
assigned_client_ids=assigned_client_ids,
)
# Check if username is already taken by another user
existing_user = User.query.filter_by(username=username).first()
if existing_user and existing_user.id != user.id:
flash(_("Username already exists"), "error")
return render_template(
"admin/user_form.html",
user=user,
clients=clients,
all_roles=all_roles,
assigned_client_ids=assigned_client_ids,
)
# Validate client portal settings
if client_portal_enabled and not client_id:
flash(_("Please select a client when enabling client portal access."), "error")
return render_template(
"admin/user_form.html",
user=user,
clients=clients,
all_roles=all_roles,
assigned_client_ids=assigned_client_ids,
)
# Get the Role object from the database
role_obj = Role.query.filter_by(name=role_name).first()
if not role_obj:
# Fallback: if role doesn't exist, try to use "user" role
role_obj = Role.query.filter_by(name="user").first()
if not role_obj:
flash(_("Default 'user' role not found. Please run 'flask seed_permissions_cmd' first."), "error")
return render_template(
"admin/user_form.html",
user=user,
clients=clients,
all_roles=all_roles,
assigned_client_ids=assigned_client_ids,
)
# Handle password reset if provided
new_password = request.form.get("new_password", "").strip()
password_confirm = request.form.get("password_confirm", "").strip()
force_password_change = request.form.get("force_password_change") == "on"
if new_password:
# Validate password
if len(new_password) < 8:
flash(_("Password must be at least 8 characters long."), "error")
return render_template(
"admin/user_form.html",
user=user,
clients=clients,
all_roles=all_roles,
assigned_client_ids=assigned_client_ids,
)
if new_password != password_confirm:
flash(_("Passwords do not match."), "error")
return render_template(
"admin/user_form.html",
user=user,
clients=clients,
all_roles=all_roles,
assigned_client_ids=assigned_client_ids,
)
# Set the new password
user.set_password(new_password)
if force_password_change:
user.password_change_required = True
else:
user.password_change_required = False
current_app.logger.info("Admin '%s' reset password for user '%s'", current_user.username, user.username)
# Update user
user.username = username
# Update legacy role field for backward compatibility
user.role = role_name
# Update roles in the new system
# If user doesn't have the selected role, assign it as the primary role
# Keep other roles if they exist (multi-role support)
if role_obj not in user.roles:
# If user has no roles, assign the selected one
if not user.roles:
user.roles.append(role_obj)
else:
# If user has roles, replace the first one (primary role) with the selected one
# This maintains backward compatibility while supporting multi-role
user.roles[0] = role_obj
else:
# If the selected role is already assigned but not first, move it to first position
if user.roles[0] != role_obj:
user.roles.remove(role_obj)
user.roles.insert(0, role_obj)
user.is_active = is_active
user.client_portal_enabled = client_portal_enabled
user.client_id = int(client_id) if client_id else None
# Subcontractor: sync assigned clients (only when role is subcontractor)
assigned_client_ids = [int(x) for x in request.form.getlist("assigned_client_ids") if x and x.isdigit()]
UserClient.query.filter_by(user_id=user.id).delete()
if role_name == "subcontractor" and assigned_client_ids:
valid_client_ids = {c.id for c in Client.query.filter(Client.id.in_(assigned_client_ids)).all()}
for cid in assigned_client_ids:
if cid in valid_client_ids:
db.session.add(UserClient(user_id=user.id, client_id=cid))
if not safe_commit("admin_edit_user", {"user_id": user.id}):
flash(_("Could not update user due to a database error. Please check server logs."), "error")
return render_template(
"admin/user_form.html",
user=user,
clients=clients,
all_roles=all_roles,
assigned_client_ids=assigned_client_ids,
)
if new_password:
flash(_('Password reset successfully for user "%(username)s"', username=username), "success")
else:
flash(_('User "%(username)s" updated successfully', username=username), "success")
return redirect(url_for("admin.list_users"))
return render_template(
"admin/user_form.html", user=user, clients=clients, all_roles=all_roles, assigned_client_ids=assigned_client_ids
)
@admin_bp.route("/admin/users/<int:user_id>/delete", methods=["POST"])
@login_required
@admin_or_permission_required("delete_users")
def delete_user(user_id):
"""Delete a user"""
user = User.query.get_or_404(user_id)
# Don't allow deleting the last admin
if user.is_admin:
admin_count = User.query.filter_by(role="admin", is_active=True).count()
if admin_count <= 1:
flash(_("Cannot delete the last administrator"), "error")
return redirect(url_for("admin.list_users"))
# Don't allow deleting users with time entries
if user.time_entries.count() > 0:
flash(_("Cannot delete user with existing time entries"), "error")
return redirect(url_for("admin.list_users"))
# Remove donation_interactions for this user so delete succeeds (FK / optional table)
try:
DonationInteraction.query.filter_by(user_id=user.id).delete()
except ProgrammingError as e:
error_str = str(e.orig) if hasattr(e, "orig") else str(e)
if "donation_interactions" in error_str and "does not exist" in error_str:
current_app.logger.warning(
"donation_interactions table missing during user delete (user_id=%s); continuing.",
user.id,
)
else:
raise
username = user.username
db.session.delete(user)
if not safe_commit("admin_delete_user", {"user_id": user.id}):
flash(_("Could not delete user due to a database error. Please check server logs."), "error")
return redirect(url_for("admin.list_users"))
flash(_('User "%(username)s" deleted successfully', username=username), "success")
return redirect(url_for("admin.list_users"))
@admin_bp.route("/admin/telemetry")
@login_required
@admin_or_permission_required("manage_telemetry")
def telemetry_dashboard():
"""Telemetry and analytics dashboard"""
installation_config = get_installation_config()
analytics_config = get_analytics_config()
# Get telemetry status
telemetry_data = {
"enabled": is_telemetry_enabled(),
"setup_complete": installation_config.is_setup_complete(),
"installation_id": installation_config.get_installation_id(),
"telemetry_salt": installation_config.get_installation_salt()[:16] + "...", # Show partial salt
"fingerprint": get_telemetry_fingerprint(),
"config": installation_config.get_all_config(),
}
# Get OTEL OTLP status
grafana_endpoint = analytics_config.get("otel_exporter_otlp_endpoint") or os.getenv(
"OTEL_EXPORTER_OTLP_ENDPOINT", ""
)
grafana_token = analytics_config.get("otel_exporter_otlp_token") or os.getenv("OTEL_EXPORTER_OTLP_TOKEN", "")
grafana_data = {
"enabled": bool(grafana_endpoint) and bool(grafana_token),
"endpoint": grafana_endpoint,
"token_set": bool(grafana_token),
}
# Get Sentry status
sentry_dsn = analytics_config.get("sentry_dsn") or os.getenv("SENTRY_DSN", "")
sentry_data = {
"enabled": bool(sentry_dsn),
"dsn_set": bool(sentry_dsn),
"traces_rate": os.getenv("SENTRY_TRACES_RATE", "0.0"),
}
# Log dashboard access
app_module.log_event("admin.telemetry_dashboard_viewed", user_id=current_user.id)
app_module.track_event(current_user.id, "admin.telemetry_dashboard_viewed", {})
return render_template("admin/telemetry.html", telemetry=telemetry_data, grafana=grafana_data, sentry=sentry_data)
@admin_bp.route("/admin/telemetry/toggle", methods=["POST"])
@login_required
@admin_or_permission_required("manage_telemetry")
def toggle_telemetry():
"""Toggle telemetry on/off"""
installation_config = get_installation_config()
current_state = installation_config.get_telemetry_preference()
new_state = not current_state
installation_config.set_telemetry_preference(new_state)
if new_state:
try:
from app.utils.telemetry import check_and_send_telemetry
check_and_send_telemetry()
except Exception as e:
safe_log(current_app.logger, "debug", "Telemetry check_and_send failed: %s", e)
app_module.log_event("admin.telemetry_toggled", user_id=current_user.id, new_state=new_state)
app_module.track_event(current_user.id, "admin.telemetry_toggled", {"enabled": new_state})
if new_state:
flash(_("Telemetry has been enabled. Thank you for helping us improve!"), "success")
else:
flash(_("Detailed analytics has been disabled. Anonymous base telemetry remains active."), "info")
return redirect(url_for("admin.telemetry_dashboard"))
@admin_bp.route("/admin/clear-cache")
@login_required
@admin_or_permission_required("manage_settings")
def clear_cache():
"""Cache clearing utility page"""
return render_template("admin/clear_cache.html")
@admin_bp.route("/admin/modules", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("manage_settings")
def manage_modules():
"""Manage module visibility - enable/disable modules system-wide"""
from app.models.client import Client
from app.utils.module_registry import ModuleCategory, ModuleRegistry
# Initialize registry
ModuleRegistry.initialize_defaults()
# Get settings to access disabled_module_ids
settings_obj = Settings.get_settings()
# For locked client selection UI
clients = Client.query.filter_by(status="active").order_by(Client.name).all()
# Module visibility: non-CORE modules for admin toggles
modules_by_category = {}
for cat in ModuleCategory:
mods = [m for m in ModuleRegistry.get_by_category(cat) if m.category != ModuleCategory.CORE]
if mods:
modules_by_category[cat] = mods
if request.method == "POST":
# Locked client: allow admin to lock the instance to a single client
locked_client_id_raw = (request.form.get("locked_client_id") or "").strip()
if locked_client_id_raw:
try:
locked_client_id = int(locked_client_id_raw)
except (TypeError, ValueError):
flash(_("Invalid locked client selection."), "error")
return render_template(
"admin/modules.html",
modules_by_category=modules_by_category,
ModuleCategory=ModuleCategory,
settings=settings_obj,
clients=clients,
)
locked_client = Client.query.get(locked_client_id)
if not locked_client or getattr(locked_client, "status", None) != "active":
flash(_("Selected locked client does not exist or is not active."), "error")
return render_template(
"admin/modules.html",
modules_by_category=modules_by_category,
ModuleCategory=ModuleCategory,
settings=settings_obj,
clients=clients,
)
settings_obj.locked_client_id = locked_client_id
else:
settings_obj.locked_client_id = None
# Module visibility: build disabled_module_ids from unchecked module_enabled_* checkboxes
if hasattr(settings_obj, "disabled_module_ids"):
disabled = []
for mods in modules_by_category.values():
for m in mods:
if ("module_enabled_" + m.id) not in request.form:
disabled.append(m.id)
# Validate module dependencies before saving
validation_errors = []
for module_id in disabled:
can_disable, affected = ModuleRegistry.validate_module_disable(module_id, disabled)
if not can_disable and affected:
module = ModuleRegistry.get(module_id)
module_name = module.name if module else module_id
affected_names = [
ModuleRegistry.get(aid).name if ModuleRegistry.get(aid) else aid for aid in affected
]
validation_errors.append(
_(
"Cannot disable '%(module)s' because the following modules depend on it: %(dependents)s",
module=module_name,
dependents=", ".join(affected_names),
)
)
if validation_errors:
for error in validation_errors:
flash(error, "error")
return render_template(
"admin/modules.html",
modules_by_category=modules_by_category,
ModuleCategory=ModuleCategory,
settings=settings_obj,
clients=clients,
)
settings_obj.disabled_module_ids = disabled
# Ensure settings object is in the session
if settings_obj not in db.session:
db.session.add(settings_obj)
if not safe_commit("admin_update_module_visibility"):
flash(
_("Could not update module visibility due to a database error. Please check server logs."), "error"
)
return render_template(
"admin/modules.html",
modules_by_category=modules_by_category,
ModuleCategory=ModuleCategory,
settings=settings_obj,
clients=clients,
)
flash(_("Module visibility updated successfully"), "success")
return redirect(url_for("admin.manage_modules"))
# Optional module load diagnostics captured during blueprint registration.
try:
from flask import current_app
blueprint_load_status = current_app.extensions.get("blueprint_load_status", []) or []
except Exception:
blueprint_load_status = []
return render_template(
"admin/modules.html",
modules_by_category=modules_by_category,
ModuleCategory=ModuleCategory,
settings=settings_obj,
clients=clients,
blueprint_load_status=blueprint_load_status,
)
@admin_bp.route("/admin/settings", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("manage_settings")
def settings():
"""Manage system settings"""
import os # Ensure os is available in function scope
settings_obj = Settings.get_settings()
installation_config = get_installation_config()
timezones = get_available_timezones()
peppol_env_enabled = (os.getenv("PEPPOL_ENABLED", "false") or "").strip().lower() in {"1", "true", "yes", "y", "on"}
ai_config = settings_obj.get_ai_config()
# Sync analytics preference from installation config to database on load
# (installation config is the source of truth for telemetry)
if settings_obj.allow_analytics != installation_config.get_telemetry_preference():
settings_obj.allow_analytics = installation_config.get_telemetry_preference()
db.session.commit()
# Prepare kiosk settings with safe defaults (in case migration hasn't run)
kiosk_settings = {
"kiosk_mode_enabled": getattr(settings_obj, "kiosk_mode_enabled", False),
"kiosk_auto_logout_minutes": getattr(settings_obj, "kiosk_auto_logout_minutes", 15),
"kiosk_allow_camera_scanning": getattr(settings_obj, "kiosk_allow_camera_scanning", True),
"kiosk_require_reason_for_adjustments": getattr(settings_obj, "kiosk_require_reason_for_adjustments", False),
"kiosk_default_movement_type": getattr(settings_obj, "kiosk_default_movement_type", "adjustment"),
}
if request.method == "POST":
# Validate timezone
timezone = request.form.get("timezone") or settings_obj.timezone
try:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
ZoneInfo(timezone) # This will raise an exception if timezone is invalid
except (ZoneInfoNotFoundError, KeyError):
flash(_("Invalid timezone: %(timezone)s", timezone=timezone), "error")
system_instance_id = Settings.get_system_instance_id()
return render_template(
"admin/settings.html",
settings=settings_obj,
timezones=timezones,
kiosk_settings=kiosk_settings,
peppol_env_enabled=peppol_env_enabled,
ai_config=ai_config,
system_instance_id=system_instance_id,
)
# Update basic settings
settings_obj.timezone = timezone
# Validate and update date/time format
date_fmt = request.form.get("date_format", "YYYY-MM-DD")
if date_fmt in ("YYYY-MM-DD", "MM/DD/YYYY", "DD/MM/YYYY", "DD.MM.YYYY"):
settings_obj.date_format = date_fmt
time_fmt = request.form.get("time_format", "24h")
if time_fmt in ("24h", "12h"):
settings_obj.time_format = time_fmt
settings_obj.currency = request.form.get("currency", "EUR")
settings_obj.rounding_minutes = int(request.form.get("rounding_minutes", 1))
settings_obj.single_active_timer = request.form.get("single_active_timer") == "on"
settings_obj.allow_self_register = request.form.get("allow_self_register") == "on"
settings_obj.idle_timeout_minutes = int(request.form.get("idle_timeout_minutes", 30))
settings_obj.backup_retention_days = int(request.form.get("backup_retention_days", 30))
settings_obj.backup_time = request.form.get("backup_time", "02:00")
settings_obj.export_delimiter = request.form.get("export_delimiter", ",")
# Update company branding settings
settings_obj.company_name = request.form.get("company_name", "Your Company Name")
settings_obj.company_address = request.form.get("company_address", "Your Company Address")
settings_obj.company_email = request.form.get("company_email", "info@yourcompany.com")
settings_obj.company_phone = request.form.get("company_phone", "+1 (555) 123-4567")
settings_obj.company_website = request.form.get("company_website", "www.yourcompany.com")
settings_obj.company_tax_id = request.form.get("company_tax_id", "")
settings_obj.company_bank_info = request.form.get("company_bank_info", "")
# Update invoice defaults
invoice_prefix_form = sanitize_invoice_prefix(request.form.get("invoice_prefix", ""))
invoice_number_pattern_form = sanitize_invoice_pattern(request.form.get("invoice_number_pattern", ""))
invoice_start_number_form = request.form.get("invoice_start_number", 1000)
is_valid_pattern, pattern_error = validate_invoice_pattern(invoice_number_pattern_form)
if not is_valid_pattern:
flash(_("Invalid invoice number pattern: %(reason)s", reason=pattern_error), "error")
system_instance_id = Settings.get_system_instance_id()
return render_template(
"admin/settings.html",
settings=settings_obj,
timezones=timezones,
kiosk_settings=kiosk_settings,
peppol_env_enabled=peppol_env_enabled,
ai_config=ai_config,
system_instance_id=system_instance_id,
)
# #region agent log
try:
import json
log_data = {
"location": "admin.py:952",
"message": "Saving invoice prefix and start number",
"data": {
"invoice_prefix_form": str(invoice_prefix_form),
"invoice_number_pattern_form": str(invoice_number_pattern_form),
"invoice_start_number_form": str(invoice_start_number_form),
"settings_obj_id": settings_obj.id if hasattr(settings_obj, "id") else "NO_ID",
},
"timestamp": int(datetime.utcnow().timestamp() * 1000),
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "F",
}
log_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".cursor", "debug.log")
with open(log_path, "a", encoding="utf-8") as f:
f.write(json.dumps(log_data) + "\n")
except (OSError, IOError, TypeError, ValueError):
pass
# #endregion
settings_obj.invoice_prefix = invoice_prefix_form
settings_obj.invoice_number_pattern = invoice_number_pattern_form
settings_obj.invoice_start_number = int(invoice_start_number_form)
settings_obj.invoice_terms = request.form.get("invoice_terms", "Payment is due within 30 days of invoice date.")
settings_obj.invoice_notes = request.form.get("invoice_notes", "Thank you for your business!")
# Update Peppol e-invoicing settings (if columns exist)
try:
mode = (request.form.get("peppol_enabled_mode") or "env").strip().lower()
if mode == "true":
settings_obj.peppol_enabled = True
elif mode == "false":
settings_obj.peppol_enabled = False
else:
settings_obj.peppol_enabled = None
settings_obj.peppol_sender_endpoint_id = (request.form.get("peppol_sender_endpoint_id", "") or "").strip()
settings_obj.peppol_sender_scheme_id = (request.form.get("peppol_sender_scheme_id", "") or "").strip()
settings_obj.peppol_sender_country = (request.form.get("peppol_sender_country", "") or "").strip()
settings_obj.peppol_access_point_url = (request.form.get("peppol_access_point_url", "") or "").strip()
token = (request.form.get("peppol_access_point_token", "") or "").strip()
if token:
settings_obj.set_secret("peppol_access_point_token", token)
try:
settings_obj.peppol_access_point_timeout = int(request.form.get("peppol_access_point_timeout", 30))
except Exception:
settings_obj.peppol_access_point_timeout = 30
settings_obj.peppol_provider = (request.form.get("peppol_provider", "") or "").strip() or "generic"
transport_mode = (request.form.get("peppol_transport_mode", "") or "generic").strip().lower()
if transport_mode not in ("generic", "native"):
transport_mode = "generic"
settings_obj.peppol_transport_mode = transport_mode
settings_obj.peppol_sml_url = (request.form.get("peppol_sml_url", "") or "").strip()
settings_obj.peppol_native_cert_path = (request.form.get("peppol_native_cert_path", "") or "").strip()
settings_obj.peppol_native_key_path = (request.form.get("peppol_native_key_path", "") or "").strip()
settings_obj.invoices_peppol_compliant = request.form.get("invoices_peppol_compliant") == "on"
settings_obj.invoices_zugferd_pdf = request.form.get("invoices_zugferd_pdf") == "on"
settings_obj.invoices_pdfa3_compliant = request.form.get("invoices_pdfa3_compliant") == "on"
settings_obj.invoices_validate_export = request.form.get("invoices_validate_export") == "on"
settings_obj.invoices_verapdf_path = (request.form.get("invoices_verapdf_path", "") or "").strip()
except AttributeError:
# Peppol columns don't exist yet (migration not run)
pass
# Update kiosk mode settings (if columns exist)
try:
settings_obj.kiosk_mode_enabled = request.form.get("kiosk_mode_enabled") == "on"
settings_obj.kiosk_auto_logout_minutes = int(request.form.get("kiosk_auto_logout_minutes", 15))
settings_obj.kiosk_allow_camera_scanning = request.form.get("kiosk_allow_camera_scanning") == "on"
settings_obj.kiosk_require_reason_for_adjustments = (
request.form.get("kiosk_require_reason_for_adjustments") == "on"
)
settings_obj.kiosk_default_movement_type = request.form.get("kiosk_default_movement_type", "adjustment")
except AttributeError:
# Kiosk columns don't exist yet (migration not run)
pass
# Update time entry requirements (if columns exist)
try:
settings_obj.time_entry_require_task = request.form.get("time_entry_require_task") == "on"
settings_obj.time_entry_require_description = request.form.get("time_entry_require_description") == "on"
min_len = int(request.form.get("time_entry_description_min_length", 20))
settings_obj.time_entry_description_min_length = max(1, min(500, min_len))
except AttributeError:
pass
# Update default daily working hours (overtime) for new users
try:
val = request.form.get("default_daily_working_hours", type=float)
if val is not None and 0.5 <= val <= 24:
settings_obj.default_daily_working_hours = val
except (AttributeError, ValueError, TypeError):
pass
# Update AI helper settings (server-side provider config; secrets are not exposed to clients)
try:
ai_enabled_mode = (request.form.get("ai_enabled_mode") or "env").strip().lower()
if ai_enabled_mode == "true":
settings_obj.ai_enabled = True
elif ai_enabled_mode == "false":
settings_obj.ai_enabled = False
else:
settings_obj.ai_enabled = None
ai_provider = (request.form.get("ai_provider") or "ollama").strip().lower()
if ai_provider not in ("ollama", "openai_compatible"):
ai_provider = "ollama"
settings_obj.ai_provider = ai_provider
settings_obj.ai_base_url = (request.form.get("ai_base_url") or "").strip()
settings_obj.ai_model = (request.form.get("ai_model") or "").strip()
if request.form.get("ai_clear_api_key") == "on":
settings_obj.set_secret("ai_api_key", "")
else:
ai_api_key = (request.form.get("ai_api_key") or "").strip()
if ai_api_key:
settings_obj.set_secret("ai_api_key", ai_api_key)
try:
settings_obj.ai_timeout_seconds = max(1, min(300, int(request.form.get("ai_timeout_seconds") or 30)))
except (TypeError, ValueError):
settings_obj.ai_timeout_seconds = None
try:
settings_obj.ai_context_limit = max(5, min(200, int(request.form.get("ai_context_limit") or 40)))
except (TypeError, ValueError):
settings_obj.ai_context_limit = None
settings_obj.ai_system_prompt = (request.form.get("ai_system_prompt") or "").strip()
except AttributeError:
pass
# Update privacy and analytics settings
allow_analytics = request.form.get("allow_analytics") == "on"
old_analytics_state = settings_obj.allow_analytics
settings_obj.allow_analytics = allow_analytics
# Also update the installation config (used by telemetry system)
# This ensures the telemetry system sees the updated preference
installation_config.set_telemetry_preference(allow_analytics)
# Log analytics preference change if it changed
if old_analytics_state != allow_analytics:
app_module.log_event("admin.analytics_toggled", user_id=current_user.id, new_state=allow_analytics)
app_module.track_event(current_user.id, "admin.analytics_toggled", {"enabled": allow_analytics})
# Ensure settings object is in the session (important for new instances)
if settings_obj not in db.session:
db.session.add(settings_obj)
if not safe_commit("admin_update_settings"):
flash(_("Could not update settings due to a database error. Please check server logs."), "error")
system_instance_id = Settings.get_system_instance_id()
return render_template(
"admin/settings.html",
settings=settings_obj,
timezones=timezones,
kiosk_settings=kiosk_settings,
peppol_env_enabled=peppol_env_enabled,
ai_config=ai_config,
system_instance_id=system_instance_id,
)
# #region agent log
try:
import json
log_data = {
"location": "admin.py:1027",
"message": "After commit - settings values",
"data": {
"invoice_prefix": str(settings_obj.invoice_prefix),
"invoice_number_pattern": str(getattr(settings_obj, "invoice_number_pattern", "")),
"invoice_start_number": int(settings_obj.invoice_start_number),
"settings_obj_id": settings_obj.id if hasattr(settings_obj, "id") else "NO_ID",
},
"timestamp": int(datetime.utcnow().timestamp() * 1000),
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "G",
}
log_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".cursor", "debug.log")
with open(log_path, "a", encoding="utf-8") as f:
f.write(json.dumps(log_data) + "\n")
except (OSError, IOError, TypeError, ValueError):
pass
# #endregion
flash(_("Settings updated successfully"), "success")
return redirect(url_for("admin.settings"))
# Update kiosk_settings after potential POST update
kiosk_settings = {
"kiosk_mode_enabled": getattr(settings_obj, "kiosk_mode_enabled", False),
"kiosk_auto_logout_minutes": getattr(settings_obj, "kiosk_auto_logout_minutes", 15),
"kiosk_allow_camera_scanning": getattr(settings_obj, "kiosk_allow_camera_scanning", True),
"kiosk_require_reason_for_adjustments": getattr(settings_obj, "kiosk_require_reason_for_adjustments", False),
"kiosk_default_movement_type": getattr(settings_obj, "kiosk_default_movement_type", "adjustment"),
}
system_instance_id = Settings.get_system_instance_id()
ai_config = settings_obj.get_ai_config()
return render_template(
"admin/settings.html",
settings=settings_obj,
timezones=timezones,
kiosk_settings=kiosk_settings,
peppol_env_enabled=peppol_env_enabled,
ai_config=ai_config,
system_instance_id=system_instance_id,
)
@admin_bp.route("/admin/ldap/test", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def admin_ldap_test():
"""Test LDAP connectivity (service bind + user subtree count). Returns JSON only."""
from app.services.ldap_service import LDAPService
return jsonify(LDAPService.test_connection())
@admin_bp.route("/admin/settings/verify-donate-hide-code", methods=["POST"])
@login_required
@admin_or_permission_required("manage_settings")
def admin_verify_donate_hide_code():
"""Verify code (Ed25519 or HMAC) and set system-wide donate_ui_hidden=True."""
import hmac
from app.utils.donate_hide_code import compute_donate_hide_code, verify_ed25519_signature
settings_obj = Settings.get_settings()
if getattr(settings_obj, "donate_ui_hidden", False):
return jsonify({"success": True})
data = request.get_json() or {}
code = (data.get("code") or "").strip()
system_id = Settings.get_system_instance_id()
if not system_id:
return jsonify({"error": _("Invalid code.")}), 400
valid = False
public_key_pem = current_app.config.get("DONATE_HIDE_PUBLIC_KEY_PEM") or ""
if public_key_pem:
valid = verify_ed25519_signature(code, system_id, public_key_pem)
if not valid:
secret = current_app.config.get("DONATE_HIDE_UNLOCK_SECRET") or ""
if secret:
expected = compute_donate_hide_code(secret, system_id)
valid = bool(expected and hmac.compare_digest(code, expected))
if not valid:
return jsonify({"error": _("Invalid code.")}), 400
settings_obj.donate_ui_hidden = True
if safe_commit(db.session):
return jsonify({"success": True})
return jsonify({"error": _("Error saving settings")}), 500
@admin_bp.route("/admin/pdf-layout", methods=["GET", "POST"])
@limiter.limit("30 per minute", methods=["POST"]) # editor saves
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout():
"""Edit PDF invoice layout template (HTML and CSS) by page size."""
from app.models import InvoicePDFTemplate
# Get page size from query parameter or form, default to A4
page_size_raw = request.args.get("size", request.form.get("page_size", "A4"))
current_app.logger.info(
f"[PDF_TEMPLATE] Action: template_editor_request, PageSize: '{page_size_raw}', Method: {request.method}, User: {current_user.username}"
)
# Ensure valid page size
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size_raw not in valid_sizes:
current_app.logger.warning(
f"[PDF_TEMPLATE] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}"
)
page_size = "A4"
else:
page_size = page_size_raw
current_app.logger.info(
f"[PDF_TEMPLATE] Final validated PageSize: '{page_size}', Method: {request.method}, User: {current_user.username}"
)
# Get or create template for this page size (ensures JSON exists)
current_app.logger.info(
f"[PDF_TEMPLATE] Retrieving template from database - PageSize: '{page_size}', User: {current_user.username}"
)
template = InvoicePDFTemplate.get_template(page_size)
current_app.logger.info(
f"[PDF_TEMPLATE] Template retrieved - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, HasDesignJSON: {bool(template.design_json)}"
)
if request.method == "POST":
current_app.logger.info(
f"[PDF_TEMPLATE] Action: template_save, PageSize: '{page_size}', User: {current_user.username}"
)
html_template = request.form.get("invoice_pdf_template_html", "")
css_template = request.form.get("invoice_pdf_template_css", "")
design_json = request.form.get("design_json", "")
template_json = request.form.get("template_json", "") # ReportLab template JSON
date_format = request.form.get("date_format", "%d.%m.%Y") # Date format for this template
current_app.logger.info(
f"[PDF_TEMPLATE] Form data received - PageSize: '{page_size}', HTML length: {len(html_template)}, CSS length: {len(css_template)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}"
)
# Validate and ensure template_json is present
import json
template_json_dict = None
if template_json and template_json.strip():
try:
current_app.logger.info(
f"[PDF_TEMPLATE] Parsing template JSON - PageSize: '{page_size}', JSON length: {len(template_json)}"
)
template_json_dict = json.loads(template_json)
# Ensure page size matches in JSON
json_page_size = template_json_dict.get("page", {}).get("size")
current_app.logger.info(
f"[PDF_TEMPLATE] Template JSON page size before update: '{json_page_size}', Target PageSize: '{page_size}'"
)
if "page" in template_json_dict and "size" in template_json_dict["page"]:
template_json_dict["page"]["size"] = page_size
else:
# Add page size if missing
if "page" not in template_json_dict:
template_json_dict["page"] = {}
template_json_dict["page"]["size"] = page_size
# CRITICAL: Ensure page dimensions (width/height) match the page size
# This fixes layout issues when templates are customized
from app.utils.pdf_template_schema import get_page_dimensions_mm
template_page_config = template_json_dict.get("page", {})
expected_dims = get_page_dimensions_mm(page_size)
current_width = template_page_config.get("width")
current_height = template_page_config.get("height")
if current_width != expected_dims["width"] or current_height != expected_dims["height"]:
current_app.logger.info(
f"[PDF_TEMPLATE] Updating template page dimensions - PageSize: '{page_size}', "
f"Old: {current_width}x{current_height}mm, New: {expected_dims['width']}x{expected_dims['height']}mm, User: {current_user.username}"
)
template_page_config["width"] = expected_dims["width"]
template_page_config["height"] = expected_dims["height"]
template_json_dict["page"] = template_page_config
template_json = json.dumps(template_json_dict)
element_count = len(template_json_dict.get("elements", []))
current_app.logger.info(
f"[PDF_TEMPLATE] Template JSON parsed and updated - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}"
)
except json.JSONDecodeError as e:
current_app.logger.error(
f"[PDF_TEMPLATE] Invalid template_json provided - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}"
)
flash(_("Invalid template JSON format. Please try again."), "error")
return redirect(url_for("admin.pdf_layout", size=page_size))
else:
# If no template_json provided, generate default
current_app.logger.warning(
f"[PDF_TEMPLATE] No template_json provided, generating default - PageSize: '{page_size}', User: {current_user.username}"
)
from app.utils.pdf_template_schema import get_default_template
template_json_dict = get_default_template(page_size)
template_json = json.dumps(template_json_dict)
element_count = len(template_json_dict.get("elements", []))
current_app.logger.info(
f"[PDF_TEMPLATE] Generated default template JSON - PageSize: '{page_size}', Elements: {element_count}, User: {current_user.username}"
)
# Normalize @page size in CSS to match the selected page size before saving
# This ensures that saved templates always have the correct page size
if css_template:
from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css
current_app.logger.info(
f"[PDF_TEMPLATE] Normalizing CSS @page size - PageSize: '{page_size}', CSS length: {len(css_template)}"
)
css_template = update_page_size_in_css(css_template, page_size)
# Validate after normalization
is_valid, found_sizes = validate_page_size_in_css(css_template, page_size)
if not is_valid:
current_app.logger.warning(
f"[PDF_TEMPLATE] CSS @page size normalization issue - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}"
)
else:
current_app.logger.info(
f"[PDF_TEMPLATE] CSS @page size normalized successfully - PageSize: '{page_size}'"
)
# Validate template_json before saving
if not template_json or not template_json.strip():
current_app.logger.error(
f"[PDF_TEMPLATE] ERROR: template_json is empty - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
)
flash(_("Error: Template JSON is empty. Please try saving again."), "error")
return redirect(url_for("admin.pdf_layout", size=page_size))
# Validate that template_json is valid JSON
try:
import json
template_json_dict_validate = json.loads(template_json)
if not isinstance(template_json_dict_validate, dict) or "page" not in template_json_dict_validate:
current_app.logger.error(
f"[PDF_TEMPLATE] ERROR: template_json is invalid (missing 'page' property) - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
)
flash(_("Error: Template JSON is invalid. Please try saving again."), "error")
return redirect(url_for("admin.pdf_layout", size=page_size))
element_count = len(template_json_dict_validate.get("elements", []))
current_app.logger.info(
f"[PDF_TEMPLATE] Template JSON validated before save - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}, TemplateID: {template.id}, User: {current_user.username}"
)
except json.JSONDecodeError as e:
current_app.logger.error(
f"[PDF_TEMPLATE] ERROR: template_json is not valid JSON - PageSize: '{page_size}', TemplateID: {template.id}, Error: {str(e)}, User: {current_user.username}"
)
flash(_("Error: Template JSON is not valid JSON. Please try saving again."), "error")
return redirect(url_for("admin.pdf_layout", size=page_size))
# Update template (save both legacy HTML/CSS and new JSON format)
current_app.logger.info(
f"[PDF_TEMPLATE] Updating template in database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
)
template.template_html = html_template
template.template_css = css_template
template.design_json = design_json
template.template_json = template_json # ReportLab template JSON (always present now)
template.date_format = date_format # Date format for this template
template.updated_at = datetime.utcnow()
# For backwards compatibility, also update Settings when saving A4 (default)
if page_size == "A4":
current_app.logger.info(
f"[PDF_TEMPLATE] Also updating Settings for A4 default - User: {current_user.username}"
)
settings_obj = Settings.get_settings()
settings_obj.invoice_pdf_template_html = html_template
settings_obj.invoice_pdf_template_css = css_template
settings_obj.invoice_pdf_design_json = design_json
current_app.logger.info(
f"[PDF_TEMPLATE] Committing template to database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
)
if not safe_commit("admin_update_pdf_layout"):
from flask_babel import gettext as _
current_app.logger.error(
f"[PDF_TEMPLATE] Database commit failed - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
)
flash(_("Could not update PDF layout due to a database error."), "error")
else:
from flask_babel import gettext as _
# Verify that template_json was actually saved
db.session.refresh(template)
if template.template_json and template.template_json.strip() and template.template_json == template_json:
current_app.logger.info(
f"[PDF_TEMPLATE] Template saved successfully - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: True, JSON length: {len(template.template_json)}, User: {current_user.username}"
)
flash(_("PDF layout updated successfully"), "success")
else:
current_app.logger.error(
f"[PDF_TEMPLATE] WARNING: Template saved but template_json verification failed - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, User: {current_user.username}"
)
flash(
_("PDF layout saved but template JSON verification failed. Please check the template."), "warning"
)
return redirect(url_for("admin.pdf_layout", size=page_size))
# Get all templates for dropdown
all_templates = InvoicePDFTemplate.get_all_templates()
current_app.logger.info(
f"[PDF_TEMPLATE] Loaded all templates for dropdown - Count: {len(all_templates)}, PageSize: '{page_size}', User: {current_user.username}"
)
# DON'T call ensure_template_json() here - it may overwrite saved templates
# Template should already have JSON if it was saved properly
# Only validate JSON if it exists - don't generate defaults that might overwrite saved templates
if template.template_json:
try:
import json
template_json_check = json.loads(template.template_json)
element_count = len(template_json_check.get("elements", []))
json_page_size = template_json_check.get("page", {}).get("size", "unknown")
current_app.logger.info(
f"[PDF_TEMPLATE] Template JSON validated - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}, TemplateID: {template.id}"
)
except Exception as e:
current_app.logger.warning(
f"[PDF_TEMPLATE] Template JSON validation check failed - PageSize: '{page_size}', Error: {str(e)}, TemplateID: {template.id}"
)
# Provide initial defaults to the template if no custom HTML/CSS saved
initial_html = template.template_html or ""
initial_css = template.template_css or ""
design_json = template.design_json or ""
template_json = template.template_json or ""
current_app.logger.info(
f"[PDF_TEMPLATE] Template loaded for editor - PageSize: '{page_size}', HTML length: {len(initial_html)}, CSS length: {len(initial_css)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}, TemplateID: {template.id}"
)
# Fallback to legacy Settings if template is empty
if not initial_html and not initial_css:
settings_obj = Settings.get_settings()
initial_html = settings_obj.invoice_pdf_template_html or ""
initial_css = settings_obj.invoice_pdf_template_css or ""
design_json = settings_obj.invoice_pdf_design_json or ""
# Load default template if still empty
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 as e:
# Log but continue - template parsing failure is not critical
current_app.logger.debug(f"Failed to parse PDF template HTML: {e}")
if not initial_css:
try:
env = current_app.jinja_env
css_src, _, _ = env.loader.get_source(env, "invoices/pdf_styles_default.css")
initial_css = css_src
except Exception as e:
# Log but continue - CSS loading failure is not critical
current_app.logger.debug(f"Failed to load default PDF CSS: {e}")
except Exception as e:
# Log but continue - PDF layout initialization failure is not critical
current_app.logger.warning(f"Failed to initialize PDF layout defaults: {e}", exc_info=True)
# Normalize @page size in initial CSS to match the selected page size
# This ensures the editor always shows the correct page size
if initial_css:
from app.utils.pdf_generator import update_page_size_in_css
initial_css = update_page_size_in_css(initial_css, page_size)
return render_template(
"admin/pdf_layout.html",
settings=Settings.get_settings(),
initial_html=initial_html,
initial_css=initial_css,
design_json=design_json,
template_json=template_json,
page_size=page_size,
all_templates=all_templates,
date_format=getattr(template, "date_format", None) or "%d.%m.%Y",
)
@admin_bp.route("/admin/pdf-layout/reset", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout_reset():
"""Reset PDF layout to defaults (clear custom templates and regenerate default JSON)."""
import json
from app.models import InvoicePDFTemplate
from app.utils.pdf_template_schema import get_default_template
# Get page size from query parameter or form, default to A4
page_size = request.args.get("size", request.form.get("page_size", "A4"))
# Ensure valid page size
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size not in valid_sizes:
page_size = "A4"
# Get or create template for this page size
template = InvoicePDFTemplate.get_template(page_size)
# Clear custom templates
template.template_html = ""
template.template_css = ""
template.design_json = ""
# Regenerate default JSON template
default_json = get_default_template(page_size)
template.template_json = json.dumps(default_json)
template.updated_at = datetime.utcnow()
# Also clear legacy Settings for A4
if page_size == "A4":
settings_obj = Settings.get_settings()
settings_obj.invoice_pdf_template_html = ""
settings_obj.invoice_pdf_template_css = ""
settings_obj.invoice_pdf_design_json = ""
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", size=page_size))
@admin_bp.route("/admin/quote-pdf-layout", methods=["GET", "POST"])
@limiter.limit("30 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def quote_pdf_layout():
"""Edit PDF quote layout template (HTML and CSS) by page size."""
from app.models import QuotePDFTemplate
# Get page size from query parameter or form, default to A4
page_size_raw = request.args.get("size", request.form.get("page_size", "A4"))
current_app.logger.info(
f"[PDF_TEMPLATE] Action: quote_template_editor_request, PageSize: '{page_size_raw}', Method: {request.method}, User: {current_user.username}"
)
# Ensure valid page size
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size_raw not in valid_sizes:
current_app.logger.warning(
f"[PDF_TEMPLATE] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}"
)
page_size = "A4"
else:
page_size = page_size_raw
current_app.logger.info(
f"[PDF_TEMPLATE] Final validated PageSize: '{page_size}', Method: {request.method}, User: {current_user.username}"
)
# Get or create template for this page size (ensures JSON exists)
current_app.logger.info(
f"[PDF_TEMPLATE] Retrieving quote template from database - PageSize: '{page_size}', User: {current_user.username}"
)
template = QuotePDFTemplate.get_template(page_size)
current_app.logger.info(
f"[PDF_TEMPLATE] Quote template retrieved - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, HasDesignJSON: {bool(template.design_json)}"
)
if request.method == "POST":
current_app.logger.info(
f"[PDF_TEMPLATE] Action: quote_template_save, PageSize: '{page_size}', User: {current_user.username}"
)
html_template = request.form.get("quote_pdf_template_html", "")
css_template = request.form.get("quote_pdf_template_css", "")
design_json = request.form.get("design_json", "")
template_json = request.form.get("template_json", "") # ReportLab template JSON
date_format = request.form.get("date_format", "%d.%m.%Y") # Date format for this template
current_app.logger.info(
f"[PDF_TEMPLATE] Form data received - PageSize: '{page_size}', HTML length: {len(html_template)}, CSS length: {len(css_template)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}"
)
# Validate and ensure template_json is present
import json
template_json_dict = None
if template_json and template_json.strip():
try:
current_app.logger.info(
f"[PDF_TEMPLATE] Parsing quote template JSON - PageSize: '{page_size}', JSON length: {len(template_json)}"
)
template_json_dict = json.loads(template_json)
# Ensure page size matches in JSON
json_page_size = template_json_dict.get("page", {}).get("size")
current_app.logger.info(
f"[PDF_TEMPLATE] Quote template JSON page size before update: '{json_page_size}', Target PageSize: '{page_size}'"
)
if "page" in template_json_dict and "size" in template_json_dict["page"]:
template_json_dict["page"]["size"] = page_size
else:
# Add page size if missing
if "page" not in template_json_dict:
template_json_dict["page"] = {}
template_json_dict["page"]["size"] = page_size
# CRITICAL: Ensure page dimensions (width/height) match the page size
# This fixes layout issues when templates are customized
from app.utils.pdf_template_schema import get_page_dimensions_mm
template_page_config = template_json_dict.get("page", {})
expected_dims = get_page_dimensions_mm(page_size)
current_width = template_page_config.get("width")
current_height = template_page_config.get("height")
if current_width != expected_dims["width"] or current_height != expected_dims["height"]:
current_app.logger.info(
f"[PDF_TEMPLATE] Updating quote template page dimensions - PageSize: '{page_size}', "
f"Old: {current_width}x{current_height}mm, New: {expected_dims['width']}x{expected_dims['height']}mm, User: {current_user.username}"
)
template_page_config["width"] = expected_dims["width"]
template_page_config["height"] = expected_dims["height"]
template_json_dict["page"] = template_page_config
template_json = json.dumps(template_json_dict)
element_count = len(template_json_dict.get("elements", []))
current_app.logger.info(
f"[PDF_TEMPLATE] Quote template JSON parsed and updated - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}"
)
except json.JSONDecodeError as e:
current_app.logger.error(
f"[PDF_TEMPLATE] Invalid quote template_json provided - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}"
)
flash(_("Invalid template JSON format. Please try again."), "error")
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
else:
# If no template_json provided, generate default
current_app.logger.warning(
f"[PDF_TEMPLATE] No quote template_json provided, generating default - PageSize: '{page_size}', User: {current_user.username}"
)
from app.utils.pdf_template_schema import get_default_template
template_json_dict = get_default_template(page_size)
template_json = json.dumps(template_json_dict)
element_count = len(template_json_dict.get("elements", []))
current_app.logger.info(
f"[PDF_TEMPLATE] Generated default quote template JSON - PageSize: '{page_size}', Elements: {element_count}, User: {current_user.username}"
)
# Normalize @page size in CSS to match the selected page size before saving
# This ensures that saved templates always have the correct page size
if css_template:
from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css
current_app.logger.info(
f"[PDF_TEMPLATE] Normalizing quote CSS @page size - PageSize: '{page_size}', CSS length: {len(css_template)}"
)
css_template = update_page_size_in_css(css_template, page_size)
# Validate after normalization
is_valid, found_sizes = validate_page_size_in_css(css_template, page_size)
if not is_valid:
current_app.logger.warning(
f"[PDF_TEMPLATE] Quote CSS @page size normalization issue - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}"
)
else:
current_app.logger.info(
f"[PDF_TEMPLATE] Quote CSS @page size normalized successfully - PageSize: '{page_size}'"
)
# Update template (save both legacy HTML/CSS and new JSON format)
current_app.logger.info(
f"[PDF_TEMPLATE] Updating quote template in database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
)
template.template_html = html_template
template.template_css = css_template
template.design_json = design_json
# Validate template_json before saving
if not template_json or not template_json.strip():
current_app.logger.error(
f"[PDF_TEMPLATE] ERROR: Quote template_json is empty - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
)
flash(_("Error: Template JSON is empty. Please try saving again."), "error")
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
# Validate that template_json is valid JSON
try:
import json
template_json_dict_validate = json.loads(template_json)
if not isinstance(template_json_dict_validate, dict) or "page" not in template_json_dict_validate:
current_app.logger.error(
f"[PDF_TEMPLATE] ERROR: Quote template_json is invalid (missing 'page' property) - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
)
flash(_("Error: Template JSON is invalid. Please try saving again."), "error")
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
element_count = len(template_json_dict_validate.get("elements", []))
current_app.logger.info(
f"[PDF_TEMPLATE] Quote template JSON validated before save - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}, TemplateID: {template.id}, User: {current_user.username}"
)
except json.JSONDecodeError as e:
current_app.logger.error(
f"[PDF_TEMPLATE] ERROR: Quote template_json is not valid JSON - PageSize: '{page_size}', TemplateID: {template.id}, Error: {str(e)}, User: {current_user.username}"
)
flash(_("Error: Template JSON is not valid JSON. Please try saving again."), "error")
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
template.template_json = template_json # ReportLab template JSON (always present now)
template.date_format = date_format # Date format for this template
template.updated_at = datetime.utcnow()
current_app.logger.info(
f"[PDF_TEMPLATE] Committing quote template to database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
)
if not safe_commit("admin_update_quote_pdf_layout"):
current_app.logger.error(
f"[PDF_TEMPLATE] Quote template database commit failed - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
)
flash(_("Could not update PDF layout due to a database error."), "error")
else:
# Verify that template_json was actually saved
db.session.refresh(template)
if template.template_json and template.template_json.strip() and template.template_json == template_json:
current_app.logger.info(
f"[PDF_TEMPLATE] Quote template saved successfully - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: True, JSON length: {len(template.template_json)}, User: {current_user.username}"
)
flash(_("PDF layout updated successfully"), "success")
else:
current_app.logger.error(
f"[PDF_TEMPLATE] WARNING: Quote template saved but template_json verification failed - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, User: {current_user.username}"
)
flash(
_("PDF layout saved but template JSON verification failed. Please check the template."), "warning"
)
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
# Get all templates for dropdown
all_templates = QuotePDFTemplate.get_all_templates()
current_app.logger.info(
f"[PDF_TEMPLATE] Loaded all quote templates for dropdown - Count: {len(all_templates)}, PageSize: '{page_size}', User: {current_user.username}"
)
# DON'T call ensure_template_json() here - it may overwrite saved templates
# Template should already have JSON if it was saved properly
# Only validate JSON if it exists - don't generate defaults that might overwrite saved templates
if template.template_json:
try:
import json
template_json_check = json.loads(template.template_json)
element_count = len(template_json_check.get("elements", []))
json_page_size = template_json_check.get("page", {}).get("size", "unknown")
current_app.logger.info(
f"[PDF_TEMPLATE] Quote template JSON validated - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}, TemplateID: {template.id}"
)
except Exception as e:
current_app.logger.warning(
f"[PDF_TEMPLATE] Quote template JSON validation check failed - PageSize: '{page_size}', Error: {str(e)}, TemplateID: {template.id}"
)
# Provide initial defaults
initial_html = template.template_html or ""
initial_css = template.template_css or ""
design_json = template.design_json or ""
template_json = template.template_json or ""
current_app.logger.info(
f"[PDF_TEMPLATE] Quote template loaded for editor - PageSize: '{page_size}', HTML length: {len(initial_html)}, CSS length: {len(initial_css)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}, TemplateID: {template.id}"
)
# Load default template if empty
try:
if not initial_html:
env = current_app.jinja_env
html_src, _unused1, _unused2 = env.loader.get_source(env, "quotes/pdf_default.html")
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 as e:
safe_log(current_app.logger, "debug", "Quote PDF template body regex failed: %s", e)
if not initial_css:
env = current_app.jinja_env
css_src, _unused3, _unused4 = env.loader.get_source(env, "quotes/pdf_styles_default.css")
initial_css = css_src
except Exception as e:
safe_log(current_app.logger, "warning", "Quote PDF layout initialization failed: %s", e)
# Normalize @page size in initial CSS to match the selected page size
# This ensures the editor always shows the correct page size
if initial_css:
from app.utils.pdf_generator import update_page_size_in_css
initial_css = update_page_size_in_css(initial_css, page_size)
return render_template(
"admin/quote_pdf_layout.html",
settings=Settings.get_settings(),
initial_html=initial_html,
initial_css=initial_css,
design_json=design_json,
template_json=template_json,
page_size=page_size,
all_templates=all_templates,
date_format=getattr(template, "date_format", None) or "%d.%m.%Y",
)
@admin_bp.route("/admin/quote-pdf-layout/reset", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def quote_pdf_layout_reset():
"""Reset quote PDF layout to defaults (clear custom templates and regenerate default JSON)."""
import json
from app.models import QuotePDFTemplate
from app.utils.pdf_template_schema import get_default_template
# Get page size from query parameter or form, default to A4
page_size = request.args.get("size", request.form.get("page_size", "A4"))
# Ensure valid page size
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size not in valid_sizes:
page_size = "A4"
# Get or create template for this page size
template = QuotePDFTemplate.get_template(page_size)
# Clear custom templates
template.template_html = ""
template.template_css = ""
template.design_json = ""
# Regenerate default JSON template
default_json = get_default_template(page_size)
template.template_json = json.dumps(default_json)
template.updated_at = datetime.utcnow()
if not safe_commit("admin_reset_quote_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.quote_pdf_layout", size=page_size))
@admin_bp.route("/admin/quote-pdf-layout/export-json/<page_size>", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def quote_pdf_layout_export_json(page_size):
"""Export quote PDF template as JSON file."""
from io import BytesIO
from app.models import QuotePDFTemplate
# Validate page size
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size not in valid_sizes:
flash(_("Invalid page size"), "error")
return redirect(url_for("admin.quote_pdf_layout", size="A4"))
# Get template
template = QuotePDFTemplate.query.filter_by(page_size=page_size).first()
if not template:
flash(_("Template not found for this page size"), "error")
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
# Get template JSON
template_json = template.template_json or "{}"
# Create file-like object
output = BytesIO()
output.write(template_json.encode("utf-8"))
output.seek(0)
# Return as downloadable file
filename = f"quote_pdf_template_{page_size}.json"
return send_file(output, mimetype="application/json", as_attachment=True, download_name=filename)
@admin_bp.route("/admin/quote-pdf-layout/import-json", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def quote_pdf_layout_import_json():
"""Import quote PDF template from JSON file."""
import json
from app.models import QuotePDFTemplate
from app.utils.pdf_template_schema import get_page_dimensions_mm
# Get page size from form or detect from JSON
page_size = request.form.get("page_size", "A4")
# Validate page size
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size not in valid_sizes:
page_size = "A4"
# Check if file was uploaded
if "json_file" not in request.files:
flash(_("No file uploaded"), "error")
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
file = request.files["json_file"]
if file.filename == "":
flash(_("No file selected"), "error")
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
# Read and parse JSON
try:
file_content = file.read().decode("utf-8")
template_json_dict = json.loads(file_content)
# Validate JSON structure
if not isinstance(template_json_dict, dict) or "page" not in template_json_dict:
flash(_("Invalid template JSON format. Missing 'page' property."), "error")
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
# Detect page size from JSON if not provided
json_page_size = template_json_dict.get("page", {}).get("size")
if json_page_size and json_page_size in valid_sizes:
page_size = json_page_size
# Update page size in JSON
template_json_dict["page"]["size"] = page_size
# Ensure page dimensions match
expected_dims = get_page_dimensions_mm(page_size)
template_page_config = template_json_dict.get("page", {})
template_page_config["width"] = expected_dims["width"]
template_page_config["height"] = expected_dims["height"]
template_json_dict["page"] = template_page_config
# Get or create template
template = QuotePDFTemplate.get_template(page_size)
# Update template JSON
template.template_json = json.dumps(template_json_dict)
template.updated_at = datetime.utcnow()
if not safe_commit("admin_import_quote_pdf_layout_json"):
flash(_("Could not import template due to a database error."), "error")
else:
flash(_("Template imported successfully"), "success")
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
except json.JSONDecodeError as e:
flash(_("Invalid JSON file: %(error)s", error=str(e)), "error")
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
except Exception as e:
current_app.logger.error(f"Error importing quote PDF template JSON: {e}", exc_info=True)
flash(_("Error importing template: %(error)s", error=str(e)), "error")
return redirect(url_for("admin.quote_pdf_layout", size=page_size))
@admin_bp.route("/admin/pdf-layout/export-json/<page_size>", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout_export_json(page_size):
"""Export invoice PDF template as JSON file."""
from io import BytesIO
from app.models import InvoicePDFTemplate
# Validate page size
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size not in valid_sizes:
flash(_("Invalid page size"), "error")
return redirect(url_for("admin.pdf_layout", size="A4"))
# Get template
template = InvoicePDFTemplate.query.filter_by(page_size=page_size).first()
if not template:
flash(_("Template not found for this page size"), "error")
return redirect(url_for("admin.pdf_layout", size=page_size))
# Get template JSON
template_json = template.template_json or "{}"
# Create file-like object
output = BytesIO()
output.write(template_json.encode("utf-8"))
output.seek(0)
# Return as downloadable file
filename = f"invoice_pdf_template_{page_size}.json"
return send_file(output, mimetype="application/json", as_attachment=True, download_name=filename)
@admin_bp.route("/admin/pdf-layout/import-json", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout_import_json():
"""Import invoice PDF template from JSON file."""
import json
from app.models import InvoicePDFTemplate
from app.utils.pdf_template_schema import get_page_dimensions_mm
# Get page size from form or detect from JSON
page_size = request.form.get("page_size", "A4")
# Validate page size
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size not in valid_sizes:
page_size = "A4"
# Check if file was uploaded
if "json_file" not in request.files:
flash(_("No file uploaded"), "error")
return redirect(url_for("admin.pdf_layout", size=page_size))
file = request.files["json_file"]
if file.filename == "":
flash(_("No file selected"), "error")
return redirect(url_for("admin.pdf_layout", size=page_size))
# Read and parse JSON
try:
file_content = file.read().decode("utf-8")
template_json_dict = json.loads(file_content)
# Validate JSON structure
if not isinstance(template_json_dict, dict) or "page" not in template_json_dict:
flash(_("Invalid template JSON format. Missing 'page' property."), "error")
return redirect(url_for("admin.pdf_layout", size=page_size))
# Detect page size from JSON if not provided
json_page_size = template_json_dict.get("page", {}).get("size")
if json_page_size and json_page_size in valid_sizes:
page_size = json_page_size
# Update page size in JSON
template_json_dict["page"]["size"] = page_size
# Ensure page dimensions match
expected_dims = get_page_dimensions_mm(page_size)
template_page_config = template_json_dict.get("page", {})
template_page_config["width"] = expected_dims["width"]
template_page_config["height"] = expected_dims["height"]
template_json_dict["page"] = template_page_config
# Get or create template
template = InvoicePDFTemplate.get_template(page_size)
# Update template JSON
template.template_json = json.dumps(template_json_dict)
template.updated_at = datetime.utcnow()
if not safe_commit("admin_import_pdf_layout_json"):
flash(_("Could not import template due to a database error."), "error")
else:
flash(_("Template imported successfully"), "success")
return redirect(url_for("admin.pdf_layout", size=page_size))
except json.JSONDecodeError as e:
flash(_("Invalid JSON file: %(error)s", error=str(e)), "error")
return redirect(url_for("admin.pdf_layout", size=page_size))
except Exception as e:
current_app.logger.error(f"Error importing invoice PDF template JSON: {e}", exc_info=True)
flash(_("Error importing template: %(error)s", error=str(e)), "error")
return redirect(url_for("admin.pdf_layout", size=page_size))
@admin_bp.route("/admin/pdf-layout/debug", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout_debug():
"""Debug endpoint to show what's saved in the database"""
settings_obj = Settings.get_settings()
html = settings_obj.invoice_pdf_template_html or ""
css = settings_obj.invoice_pdf_template_css or ""
design_json = settings_obj.invoice_pdf_design_json or ""
# Check for bugs
has_all_bug = "invoice.items.all()" in html
has_if_bug = "invoice.items and invoice.items.all()" in html
# Get invoice info for testing
from app.models import Invoice
test_invoice = Invoice.query.order_by(Invoice.id.desc()).first()
debug_info = {
"saved_template": {
"html_length": len(html),
"css_length": len(css),
"design_json_length": len(design_json),
"has_html": bool(html),
"has_bugs": has_all_bug or has_if_bug,
"bugs_found": [],
},
"test_invoice": {
"exists": test_invoice is not None,
"invoice_number": test_invoice.invoice_number if test_invoice else None,
"items_count": test_invoice.items.count() if test_invoice else 0,
},
}
if has_all_bug:
debug_info["saved_template"]["bugs_found"].append("invoice.items.all() found in template")
if has_if_bug:
debug_info["saved_template"]["bugs_found"].append("invoice.items and invoice.items.all() found in template")
# Show snippets of problematic code
if has_all_bug or has_if_bug:
import re
matches = re.finditer(r".{0,50}invoice\.items\.all\(\).{0,50}", html)
debug_info["saved_template"]["bug_snippets"] = [m.group() for m in matches]
return jsonify(debug_info)
@admin_bp.route("/admin/pdf-layout/default", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
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 as e:
safe_log(current_app.logger, "debug", "Invoice PDF template body regex failed: %s", e)
except Exception as e:
safe_log(current_app.logger, "warning", "Invoice PDF layout initialization failed: %s", e)
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 as e:
safe_log(current_app.logger, "debug", "Invoice PDF default CSS load failed: %s", e)
css_src = ""
return jsonify(
{
"html": html_src,
"css": css_src,
}
)
@admin_bp.route("/admin/pdf-layout/preview", methods=["POST"])
@limiter.limit("60 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout_preview():
"""Render a live preview of the provided HTML/CSS or JSON template using an invoice context."""
html = request.form.get("html", "")
css = request.form.get("css", "")
template_json_str = request.form.get("template_json", "") # JSON template from editor
page_size_raw = request.form.get("page_size", "A4") # Get page size from form
invoice_id = request.form.get("invoice_id", type=int)
current_app.logger.info(
f"[PDF_PREVIEW] Action: invoice_preview_request, PageSize: '{page_size_raw}', HTML length: {len(html)}, CSS length: {len(css)}, TemplateJSON length: {len(template_json_str)}, InvoiceID: {invoice_id}, User: {current_user.username}"
)
# Validate page size
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size_raw not in valid_sizes:
current_app.logger.warning(
f"[PDF_PREVIEW] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}"
)
page_size = "A4"
else:
page_size = page_size_raw
current_app.logger.info(
f"[PDF_PREVIEW] Final validated PageSize: '{page_size}', TemplateJSON provided: {bool(template_json_str and template_json_str.strip())}"
)
# Prefer form template_json (current canvas, including unsaved edits); fall back to saved DB template
import json
from app.models import InvoicePDFTemplate
from app.utils.pdf_template_schema import get_page_dimensions_mm
template_json_parsed = None
saved_template = InvoicePDFTemplate.query.filter_by(page_size=page_size).first()
if template_json_str and template_json_str.strip():
try:
current_app.logger.info(
f"[PDF_PREVIEW] Parsing form-provided JSON template (preferred for live preview) - PageSize: '{page_size}', JSON length: {len(template_json_str)}"
)
template_json_parsed = json.loads(template_json_str)
if isinstance(template_json_parsed, dict):
template_json_parsed.setdefault("page", {})
template_json_parsed["page"]["size"] = page_size
expected_dims = get_page_dimensions_mm(page_size)
template_json_parsed["page"]["width"] = expected_dims["width"]
template_json_parsed["page"]["height"] = expected_dims["height"]
element_count = (
len(template_json_parsed.get("elements", [])) if isinstance(template_json_parsed, dict) else 0
)
json_page_size = (
template_json_parsed.get("page", {}).get("size", "unknown")
if isinstance(template_json_parsed, dict)
else "unknown"
)
current_app.logger.info(
f"[PDF_PREVIEW] Form JSON template parsed and page normalized - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}"
)
except json.JSONDecodeError as e:
current_app.logger.warning(
f"[PDF_PREVIEW] Invalid form template_json, will try database - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}"
)
template_json_parsed = None
if (
template_json_parsed is None
and saved_template
and saved_template.template_json
and saved_template.template_json.strip()
):
try:
current_app.logger.info(
f"[PDF_PREVIEW] Loading saved template JSON from database (fallback) - PageSize: '{page_size}', TemplateID: {saved_template.id}, JSON length: {len(saved_template.template_json)}"
)
template_json_parsed = json.loads(saved_template.template_json)
element_count = len(template_json_parsed.get("elements", []))
json_page_size = template_json_parsed.get("page", {}).get("size", "unknown")
current_app.logger.info(
f"[PDF_PREVIEW] Saved template JSON loaded - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}"
)
except json.JSONDecodeError as e:
current_app.logger.error(
f"[PDF_PREVIEW] Failed to parse saved template JSON - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}",
exc_info=True,
)
template_json_parsed = None
invoice = None
if invoice_id:
invoice = Invoice.query.get(invoice_id)
if invoice is None:
flash(_("Invoice not found"), "error")
return redirect(url_for("admin.settings"))
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(
id=None,
invoice_number="0000",
issue_date=date.today(),
due_date=date.today(),
status="draft",
client_name="Sample Client",
client_email="",
client_address="",
client=SimpleNamespace(name="Sample Client", email="", address=""),
project=SimpleNamespace(name="Sample Project", description=""),
items=[],
extra_goods=[],
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=""
)
# Create a wrapper object with converted Query objects to lists
# We can't modify SQLAlchemy model attributes directly, so we create a wrapper
invoice_wrapper = SimpleNamespace()
# Copy all simple attributes from the invoice
for attr in [
"id",
"invoice_number",
"project_id",
"client_name",
"client_email",
"client_address",
"client_id",
"issue_date",
"due_date",
"status",
"subtotal",
"tax_rate",
"tax_amount",
"total_amount",
"currency_code",
"notes",
"terms",
"payment_date",
"payment_method",
"payment_reference",
"payment_notes",
"amount_paid",
"payment_status",
"created_by",
"created_at",
"updated_at",
]:
try:
setattr(invoice_wrapper, attr, getattr(invoice, attr))
except AttributeError:
pass
# Copy relationship attributes (project, client)
_invoice_id = getattr(invoice, "id", None)
try:
invoice_wrapper.project = invoice.project
except (AttributeError, RuntimeError) as e:
current_app.logger.debug(f"Could not access invoice.project for invoice {_invoice_id}: {e}")
invoice_wrapper.project = SimpleNamespace(name="Sample Project", description="")
try:
invoice_wrapper.client = getattr(invoice, "client", None)
except (AttributeError, RuntimeError) as e:
current_app.logger.debug(f"Could not access invoice.client for invoice {_invoice_id}: {e}")
invoice_wrapper.client = None
# Convert items from Query to list
try:
if hasattr(invoice, "items") and hasattr(invoice.items, "all"):
# It's a SQLAlchemy Query object - call .all() to get list
items_list = invoice.items.all()
if not items_list:
# No items in database, add sample
items_list = [sample_item]
invoice_wrapper.items = items_list
elif hasattr(invoice, "items") and isinstance(invoice.items, list):
# Already a list
invoice_wrapper.items = invoice.items if invoice.items else [sample_item]
else:
# Fallback
invoice_wrapper.items = [sample_item]
except Exception as e:
print(f"Error converting invoice items: {e}")
invoice_wrapper.items = [sample_item]
# Convert extra_goods from Query to list
try:
if hasattr(invoice, "extra_goods") and hasattr(invoice.extra_goods, "all"):
invoice_wrapper.extra_goods = invoice.extra_goods.all()
elif hasattr(invoice, "extra_goods") and isinstance(invoice.extra_goods, list):
invoice_wrapper.extra_goods = invoice.extra_goods
else:
invoice_wrapper.extra_goods = []
except Exception:
invoice_wrapper.extra_goods = []
# Convert expenses from Query to list
try:
if hasattr(invoice, "expenses") and hasattr(invoice.expenses, "all"):
invoice_wrapper.expenses = invoice.expenses.all()
elif hasattr(invoice, "expenses") and isinstance(invoice.expenses, list):
invoice_wrapper.expenses = invoice.expenses
else:
invoice_wrapper.expenses = []
except Exception:
invoice_wrapper.expenses = []
# Build combined all_line_items for preview (items + extra_goods + expenses) to match PDF export
all_line_items = []
for item in invoice_wrapper.items:
all_line_items.append(
SimpleNamespace(
description=getattr(item, "description", str(item)) or "",
quantity=getattr(item, "quantity", 1),
unit_price=getattr(item, "unit_price", 0),
total_amount=getattr(item, "total_amount", 0),
)
)
for good in invoice_wrapper.extra_goods:
desc_parts = [getattr(good, "name", str(good)) or ""]
if getattr(good, "description", None):
desc_parts.append(str(good.description))
if getattr(good, "sku", None):
desc_parts.append(f"SKU: {good.sku}")
if getattr(good, "category", None):
desc_parts.append(f"Category: {good.category.title()}")
all_line_items.append(
SimpleNamespace(
description="\n".join(desc_parts),
quantity=getattr(good, "quantity", 1),
unit_price=getattr(good, "unit_price", 0),
total_amount=getattr(good, "total_amount", 0),
)
)
for expense in invoice_wrapper.expenses:
desc_parts = [getattr(expense, "title", str(expense)) or ""]
if getattr(expense, "description", None):
desc_parts.append(str(expense.description))
amt = getattr(expense, "total_amount", None) or getattr(expense, "amount", 0)
all_line_items.append(
SimpleNamespace(
description="\n".join(desc_parts),
quantity=1,
unit_price=amt,
total_amount=amt,
)
)
invoice_wrapper.all_line_items = all_line_items
# Use the wrapper instead of the original invoice
invoice = invoice_wrapper
# CRITICAL: Always use template_json for preview - convert to HTML/CSS with actual invoice data
if template_json_parsed:
try:
# Convert JSON template to HTML/CSS with actual invoice data for better table rendering
html, css = _convert_json_template_to_html_css(
template_json_parsed, page_size, invoice=invoice, quote=None, settings=settings_obj
)
items_count = len(invoice.items) if hasattr(invoice, "items") and invoice.items else 0
current_app.logger.info(
f"[PDF_PREVIEW] JSON template converted with invoice data - PageSize: '{page_size}', HTML length: {len(html)}, CSS length: {len(css)}, Items count: {items_count}"
)
except Exception as e:
current_app.logger.error(
f"[PDF_PREVIEW] Failed to convert JSON template with invoice data - PageSize: '{page_size}', Error: {str(e)}",
exc_info=True,
)
# Fall back to empty HTML/CSS
html = "<div class='invoice-wrapper'></div>"
css = ""
else:
# No template_json in form or database (or both failed to parse)
current_app.logger.error(
f"[PDF_PREVIEW] No template JSON available for preview - PageSize: '{page_size}', SavedTemplateExists: {saved_template is not None}, SavedTemplateHasJSON: {bool(saved_template and saved_template.template_json and saved_template.template_json.strip())}, FormTemplateProvided: {bool(template_json_str and template_json_str.strip())}, User: {current_user.username}"
)
html = "<div class='invoice-wrapper'><p style='color:red; padding:20px;'>Error: No template found. Add content in the editor or save a template first.</p></div>"
css = ""
# CRITICAL: Load the saved template CSS for this page size and merge with editor CSS
# The editor generates minimal CSS, but we need the full template CSS for proper preview
import re
from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css
saved_css = None # Initialize saved_css to avoid UnboundLocalError
if saved_template:
current_app.logger.info(
f"[PDF_PREVIEW] Retrieved saved invoice template - PageSize: '{page_size}', TemplateID: {saved_template.id}, HasCSS: {bool(saved_template.template_css)}"
)
if saved_template.template_css and saved_template.template_css.strip():
# Use the saved template CSS as base, but normalize it first to ensure correct @page size
saved_css = saved_template.template_css
# CRITICAL: Normalize the saved template CSS to ensure it has the correct @page size
saved_css = update_page_size_in_css(saved_css, page_size)
current_app.logger.info(
f"[PDF_PREVIEW] Using saved invoice template CSS - PageSize: '{page_size}', CSS length: {len(saved_css)}, TemplateID: {saved_template.id}"
)
# If editor provided CSS, merge it (editor CSS takes precedence for @page rules)
if css and css.strip():
# Extract @page rule from editor CSS if present
editor_page_match = re.search(r"@page\s*\{[^}]*\}", css, re.IGNORECASE | re.DOTALL)
if editor_page_match:
# Editor has @page rule - normalize it and use it, merge with saved CSS
editor_page_rule = editor_page_match.group(0)
# Normalize editor's @page rule to correct size FIRST
editor_page_rule = update_page_size_in_css(editor_page_rule, page_size)
# Remove @page from saved CSS and add normalized editor's @page rule
if saved_css:
saved_css_no_page = re.sub(r"@page\s*\{[^}]*\}", "", saved_css, flags=re.IGNORECASE | re.DOTALL)
else:
saved_css_no_page = ""
# Remove @page rule from editor CSS and merge
editor_css_no_page = css.replace(editor_page_rule, "").strip()
css = editor_page_rule + "\n" + saved_css_no_page
if editor_css_no_page:
css = css + "\n" + editor_css_no_page
else:
# No @page in editor CSS, use saved CSS (already normalized) and add editor CSS
if saved_css:
css = saved_css + "\n" + css
# else: css already has the editor CSS, no need to merge
else:
# No editor CSS, use saved template CSS (already normalized) if available
if saved_css:
css = saved_css
elif not css or not css.strip():
# No template CSS and no editor CSS - create default with correct page size
css = f"@page {{\n size: {page_size};\n margin: 2cm;\n}}\n"
# Normalize @page size in CSS to match the selected page size
# This ensures preview matches what will be exported
if css:
# Always normalize @page size to ensure it matches the selected page size
css_before = css
css = update_page_size_in_css(css, page_size)
# Log if normalization changed anything
if css != css_before:
current_app.logger.debug(f"PDF Preview - CSS @page size normalized from template/editor to {page_size}")
# Validate after normalization
is_valid, found_sizes = validate_page_size_in_css(css, page_size)
if not is_valid:
current_app.logger.warning(
f"Invoice PDF preview CSS @page size normalization failed for {page_size}. "
f"Found sizes: {found_sizes}. Forcing correct size."
)
# Force add @page rule if validation failed
if "@page" not in css:
css = f"@page {{\n size: {page_size};\n margin: 2cm;\n}}\n\n" + css
else:
# Try to fix it by replacing any existing @page size
# Use a more robust regex that handles quotes and whitespace
css = re.sub(
r"size\s*:\s*['\"]?[^;}\n]+['\"]?", f"size: {page_size}", css, flags=re.IGNORECASE | re.MULTILINE
)
else:
# No CSS provided, add default @page rule
css = update_page_size_in_css("", page_size)
# Final validation and logging
is_valid, found_sizes = validate_page_size_in_css(css, page_size)
if is_valid:
current_app.logger.info(
f"[PDF_PREVIEW] CSS validated successfully - PageSize: '{page_size}', Final CSS length: {len(css)}, Final HTML length: {len(html)}"
)
else:
current_app.logger.error(
f"[PDF_PREVIEW] CSS validation FAILED - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}"
)
# Helper: remove @page rules from HTML inline styles when separate CSS exists
# This matches the fix used in PDF exports to avoid conflicts with WeasyPrint
def remove_page_rule_from_html(html_text):
"""Remove @page rules from HTML inline styles to avoid conflicts with separate CSS"""
import re
def remove_from_style_tag(match):
style_content = match.group(2)
# Remove @page rule from style content
# Need to handle nested @bottom-center rules properly
# Match @page { ... } including any nested rules
brace_count = 0
page_pattern = r"@page\s*\{"
page_match = re.search(page_pattern, style_content, re.IGNORECASE)
if page_match:
start = page_match.start()
# Find matching closing brace
end = len(style_content)
for i in range(page_match.end() - 1, len(style_content)):
if style_content[i] == "{":
brace_count += 1
elif style_content[i] == "}":
brace_count -= 1
if brace_count == 0:
end = i + 1
break
# Remove the @page rule
style_content = style_content[:start] + style_content[end:]
# Clean up any double newlines or extra whitespace
style_content = re.sub(r"\n\s*\n", "\n", style_content)
return f"{match.group(1)}{style_content}{match.group(3)}"
# Match <style> tags and remove @page rules from them
style_pattern = r"(<style[^>]*>)(.*?)(</style>)"
if re.search(style_pattern, html_text, re.IGNORECASE | re.DOTALL):
html_text = re.sub(style_pattern, remove_from_style_tag, html_text, flags=re.IGNORECASE | re.DOTALL)
return html_text
# Apply @page rule removal fix: if we have separate CSS and HTML with inline styles,
# remove @page rules from HTML to ensure the separate CSS @page rule is used
html_has_inline_styles = html and "<style>" in html
if html_has_inline_styles and css and css.strip():
# Check if HTML has @page rules
import re
html_page_rules = re.findall(r"@page\s*\{[^}]*\}", html, re.IGNORECASE | re.DOTALL)
if html_page_rules:
current_app.logger.debug(
f"PDF preview: Found {len(html_page_rules)} @page rule(s) in HTML inline styles - removing them"
)
# Remove @page rules from HTML inline styles (keep everything else)
html = remove_page_rule_from_html(html)
current_app.logger.debug("PDF preview: Removed @page rules from HTML inline styles")
# Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor
def _sanitize_jinja_blocks(raw: str) -> str:
try:
import html as _html
import re as _re
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:
# Use DD.MM.YYYY format for invoices and quotes
return value.strftime("%d.%m.%Y") if value else ""
except Exception:
return str(value) if value else ""
def _format_money(value):
try:
return f"{float(value):,.2f} {settings_obj.currency}"
except Exception:
return f"{value} {settings_obj.currency}"
# Helper function for logo - converts to base64 data URI
def _get_logo_base64(logo_path):
try:
if not logo_path or not os.path.exists(logo_path):
return None
import base64
import mimetypes
with open(logo_path, "rb") as f:
data = base64.b64encode(f.read()).decode("utf-8")
mime_type, _ = mimetypes.guess_type(logo_path)
if not mime_type:
mime_type = "image/png"
return f"data:{mime_type};base64,{data}"
except Exception as e:
print(f"Error loading logo: {e}")
return None
current_app.logger.info(
f"[PDF_PREVIEW] Rendering template string - PageSize: '{page_size}', InvoiceID: {invoice_id}, Sanitized HTML length: {len(sanitized)}"
)
body_html = render_sandboxed_string(
sanitized,
autoescape=True,
invoice=invoice,
settings=settings_obj,
Path=_Path,
format_date=_format_date,
format_money=_format_money,
get_logo_base64=_get_logo_base64,
item=sample_item,
)
current_app.logger.info(
f"[PDF_PREVIEW] Template rendered successfully - PageSize: '{page_size}', Rendered HTML length: {len(body_html)}"
)
except Exception as e:
import traceback
error_details = traceback.format_exc()
current_app.logger.error(
f"[PDF_PREVIEW] Template render error - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}",
exc_info=True,
)
body_html = (
f"<div style='color:red; padding:20px; border:2px solid red; margin:20px;'><h3>Template error:</h3><pre>{str(e)}</pre><pre>{error_details}</pre></div>"
+ sanitized
)
# Get page dimensions for preview styling
page_dimensions = InvoicePDFTemplate.PAGE_SIZES.get(page_size, InvoicePDFTemplate.PAGE_SIZES["A4"])
page_width_mm = page_dimensions["width"]
page_height_mm = page_dimensions["height"]
# Convert mm to pixels at 96 DPI (standard browser DPI for PDF preview)
# 1 inch = 25.4mm, 96 DPI = 96 pixels per inch
# Account for margins (typically 20mm = ~75px at 96 DPI)
margin_px = int((20 / 25.4) * 96) # 20mm margin in pixels
# Don't subtract margins from page dimensions - margins are applied to content, not page size
# Calculate full page dimensions at 96 DPI for browser preview
page_width_px = int((page_width_mm / 25.4) * 96)
page_height_px = int((page_height_mm / 25.4) * 96)
# Build complete HTML page with embedded styles
# Build complete HTML page with embedded styles
# For preview, scale to fit viewport while maintaining aspect ratio
page_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice Preview ({page_size})</title>
<style>{css}
/* Preview-specific styles - completely new approach */
* {{
box-sizing: border-box;
margin: 0;
padding: 0;
}}
html {{
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
overflow: auto !important;
overflow-x: auto !important;
overflow-y: auto !important;
background-color: #e5e7eb !important;
}}
body {{
width: 100% !important;
min-width: 100% !important;
margin: 0 !important;
padding: 20px !important;
padding-top: 20px !important;
overflow: auto !important;
overflow-x: auto !important;
overflow-y: auto !important;
background-color: #e5e7eb !important;
display: flex !important;
align-items: flex-start !important;
justify-content: center !important;
box-sizing: border-box !important;
min-height: 100vh !important;
}}
.preview-container {{
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
border-radius: 8px;
overflow: visible;
position: relative;
width: {page_width_px}px;
min-width: {page_width_px}px;
max-width: {page_width_px}px;
display: flex;
align-items: flex-start;
justify-content: center;
transition: transform 0.2s ease;
transform-origin: top center;
margin-top: 20px;
}}
.preview-container .invoice-wrapper,
.preview-container .quote-wrapper {{
width: {page_width_px}px !important;
min-width: {page_width_px}px !important;
max-width: {page_width_px}px !important;
height: {page_height_px}px !important;
min-height: {page_height_px}px !important;
max-height: {page_height_px}px !important;
box-sizing: border-box !important;
overflow: visible !important;
margin: 0 auto !important;
padding: 0 !important;
background: transparent !important;
position: relative;
/* CSS zoom on container will scale this proportionally */
}}
</style>
<!-- Zoom is now controlled by the parent iframe's JavaScript -->
<script>
// Minimal script - zoom is handled by parent window
(function() {{
// Ensure container maintains correct dimensions
const container = document.querySelector('.preview-container');
if (container) {{
container.style.width = {page_width_px} + 'px';
container.style.minWidth = {page_width_px} + 'px';
container.style.maxWidth = {page_width_px} + 'px';
}}
}})();
</script>
</head>
<body>
<div class="preview-container">
{body_html}
</div>
</body>
</html>"""
current_app.logger.info(
f"[PDF_PREVIEW] Returning invoice preview HTML - PageSize: '{page_size}', Total HTML length: {len(page_html)}, PageWidth: {page_width_px}px, PageHeight: {page_height_px}px, User: {current_user.username}"
)
return page_html
@admin_bp.route("/admin/quote-pdf-layout/preview", methods=["POST"])
@limiter.limit("60 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def quote_pdf_layout_preview():
"""Render a live preview of the provided HTML/CSS or JSON template using a quote context."""
# Extract and validate page_size FIRST, before any other processing
page_size_raw = request.form.get("page_size", "A4")
html = request.form.get("html", "")
css = request.form.get("css", "")
template_json_str = request.form.get("template_json", "") # JSON template from editor
quote_id = request.form.get("quote_id", type=int)
current_app.logger.info(
f"[PDF_PREVIEW] Action: quote_preview_request, PageSize: '{page_size_raw}', HTML length: {len(html)}, CSS length: {len(css)}, TemplateJSON length: {len(template_json_str)}, QuoteID: {quote_id}, User: {current_user.username}"
)
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
if page_size_raw not in valid_sizes:
current_app.logger.warning(
f"[PDF_PREVIEW] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}"
)
page_size = "A4"
else:
page_size = page_size_raw
current_app.logger.info(
f"[PDF_PREVIEW] Final validated PageSize: '{page_size}', TemplateJSON provided: {bool(template_json_str and template_json_str.strip())}"
)
# Store template_json for later conversion with quote data
template_json_parsed = None
if template_json_str and template_json_str.strip():
import json
try:
current_app.logger.info(
f"[PDF_PREVIEW] Parsing quote JSON template - PageSize: '{page_size}', JSON length: {len(template_json_str)}"
)
template_json_parsed = json.loads(template_json_str)
element_count = len(template_json_parsed.get("elements", []))
json_page_size = template_json_parsed.get("page", {}).get("size", "unknown")
current_app.logger.info(
f"[PDF_PREVIEW] Quote JSON template parsed - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}"
)
# Will convert to HTML/CSS after quote data is loaded below
except json.JSONDecodeError as e:
current_app.logger.warning(
f"[PDF_PREVIEW] Invalid quote template_json, falling back to HTML/CSS - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}"
)
template_json_parsed = None
# Fall through to use provided HTML/CSS
quote = None
if quote_id:
quote = Quote.query.get(quote_id)
if quote is None:
flash(_("Quote not found"), "error")
return redirect(url_for("admin.settings"))
if quote is None:
quote = Quote.query.order_by(Quote.id.desc()).first()
settings_obj = Settings.get_settings()
# Provide a minimal mock quote if none exists to avoid template errors
from types import SimpleNamespace
if quote is None:
from datetime import date, datetime
quote = SimpleNamespace(
id=1,
quote_number="Q-0001",
title="Sample Quote",
description="Sample quote description",
status="draft",
client_id=1,
client=SimpleNamespace(
name="Sample Client",
email="client@example.com",
address="123 Sample Street\nSample City, ST 12345",
phone="+1 234 567 8900",
),
project_id=None,
project=None,
items=[],
subtotal=0.0,
discount_type=None,
discount_amount=0.0,
discount_reason=None,
coupon_code=None,
tax_rate=0.0,
tax_amount=0.0,
total_amount=0.0,
currency_code="EUR",
valid_until=date.today(),
sent_at=None,
accepted_at=None,
notes="",
terms="",
payment_terms=None,
created_at=datetime.now(),
updated_at=datetime.now(),
created_by=1,
)
# 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)
# Create a wrapper object with converted Query objects to lists
quote_wrapper = SimpleNamespace()
# Copy all simple attributes from the quote
for attr in [
"id",
"quote_number",
"title",
"description",
"status",
"client_id",
"project_id",
"subtotal",
"discount_type",
"discount_amount",
"discount_reason",
"coupon_code",
"tax_rate",
"tax_amount",
"total_amount",
"currency_code",
"valid_until",
"sent_at",
"accepted_at",
"notes",
"terms",
"payment_terms",
"created_at",
"updated_at",
"created_by",
]:
try:
setattr(quote_wrapper, attr, getattr(quote, attr))
except AttributeError:
pass
# Copy relationship attributes (project, client)
try:
quote_wrapper.project = quote.project
except (AttributeError, RuntimeError) as e:
current_app.logger.debug(f"Could not access quote.project for quote {quote.id}: {e}")
quote_wrapper.project = None
try:
quote_wrapper.client = quote.client
except (AttributeError, RuntimeError) as e:
current_app.logger.debug(f"Could not access quote.client for quote {quote.id}: {e}")
quote_wrapper.client = SimpleNamespace(
name="Sample Client",
email="client@example.com",
address="123 Sample Street\nSample City, ST 12345",
phone="+1 234 567 8900",
)
# Convert items from Query to list
try:
if hasattr(quote, "items") and hasattr(quote.items, "all"):
# It's a SQLAlchemy Query object - call .all() to get list
items_list = quote.items.all()
if not items_list:
# No items in database, add sample
items_list = [sample_item]
quote_wrapper.items = items_list
elif hasattr(quote, "items") and isinstance(quote.items, list):
# Already a list
quote_wrapper.items = quote.items if quote.items else [sample_item]
else:
# Fallback
quote_wrapper.items = [sample_item]
except Exception as e:
print(f"Error converting quote items: {e}")
quote_wrapper.items = [sample_item]
# Use the wrapper instead of the original quote
quote = quote_wrapper
# If we have template_json, convert it to HTML/CSS for preview with actual quote data
if template_json_parsed:
try:
# Convert JSON template to HTML/CSS with actual quote data for better table rendering
html, css = _convert_json_template_to_html_css(
template_json_parsed, page_size, invoice=None, quote=quote, settings=settings_obj
)
items_count = len(quote.items) if hasattr(quote, "items") and quote.items else 0
current_app.logger.info(
f"[PDF_PREVIEW] Quote JSON template converted with quote data - PageSize: '{page_size}', HTML length: {len(html)}, CSS length: {len(css)}, Items count: {items_count}"
)
except Exception as e:
current_app.logger.error(
f"[PDF_PREVIEW] Failed to convert quote JSON template with quote data - PageSize: '{page_size}', Error: {str(e)}",
exc_info=True,
)
# Fall back to empty HTML/CSS
html = "<div class='quote-wrapper'></div>"
css = ""
# CRITICAL: Load the saved template CSS for this page size and merge with editor CSS
# The editor generates minimal CSS, but we need the full template CSS for proper preview
import re
from app.models import QuotePDFTemplate
from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css
template = QuotePDFTemplate.query.filter_by(page_size=page_size).first()
saved_css = None # Initialize saved_css to avoid UnboundLocalError
if template:
current_app.logger.info(
f"[PDF_PREVIEW] Retrieved saved quote template - PageSize: '{page_size}', TemplateID: {template.id}, HasCSS: {bool(template.template_css)}"
)
if template.template_css and template.template_css.strip():
# Use the saved template CSS as base, but normalize it first to ensure correct @page size
saved_css = template.template_css
# CRITICAL: Normalize the saved template CSS to ensure it has the correct @page size
saved_css = update_page_size_in_css(saved_css, page_size)
current_app.logger.info(
f"[PDF_PREVIEW] Using saved quote template CSS - PageSize: '{page_size}', CSS length: {len(saved_css)}, TemplateID: {template.id}"
)
# If editor provided CSS, merge it (editor CSS takes precedence for @page rules)
if css and css.strip():
# Extract @page rule from editor CSS if present
editor_page_match = re.search(r"@page\s*\{[^}]*\}", css, re.IGNORECASE | re.DOTALL)
if editor_page_match:
# Editor has @page rule - normalize it and use it, merge with saved CSS
editor_page_rule = editor_page_match.group(0)
# Normalize editor's @page rule to correct size FIRST
editor_page_rule = update_page_size_in_css(editor_page_rule, page_size)
# Remove @page from saved CSS and add normalized editor's @page rule
if saved_css:
saved_css_no_page = re.sub(r"@page\s*\{[^}]*\}", "", saved_css, flags=re.IGNORECASE | re.DOTALL)
else:
saved_css_no_page = ""
# Remove @page rule from editor CSS and merge
editor_css_no_page = css.replace(editor_page_rule, "").strip()
css = editor_page_rule + "\n" + saved_css_no_page
if editor_css_no_page:
css = css + "\n" + editor_css_no_page
else:
# No @page in editor CSS, use saved CSS (already normalized) and add editor CSS
if saved_css:
css = saved_css + "\n" + css
# else: css already has the editor CSS, no need to merge
else:
# No editor CSS, use saved template CSS (already normalized) if available
if saved_css:
css = saved_css
elif not css or not css.strip():
# No template CSS and no editor CSS - create default with correct page size
css = f"@page {{\n size: {page_size};\n margin: 2cm;\n}}\n"
# Normalize @page size in CSS to match the selected page size
# This ensures preview matches what will be exported
if css:
# Always normalize @page size to ensure it matches the selected page size
css_before = css
css = update_page_size_in_css(css, page_size)
# Log if normalization changed anything
if css != css_before:
current_app.logger.debug(
f"Quote PDF Preview - CSS @page size normalized from template/editor to {page_size}"
)
# Validate after normalization
is_valid, found_sizes = validate_page_size_in_css(css, page_size)
if not is_valid:
current_app.logger.warning(
f"[PDF_PREVIEW] Quote CSS @page size normalization failed - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}"
)
# Force add @page rule if validation failed
if "@page" not in css:
css = f"@page {{\n size: {page_size};\n margin: 2cm;\n}}\n\n" + css
else:
# Try to fix it by replacing any existing @page size
# Use a more robust regex that handles quotes and whitespace
css = re.sub(
r"size\s*:\s*['\"]?[^;}\n]+['\"]?", f"size: {page_size}", css, flags=re.IGNORECASE | re.MULTILINE
)
else:
# No CSS provided, add default @page rule
css = update_page_size_in_css("", page_size)
# Final validation and logging
is_valid, found_sizes = validate_page_size_in_css(css, page_size)
if is_valid:
current_app.logger.info(
f"[PDF_PREVIEW] Quote CSS validated successfully - PageSize: '{page_size}', Final CSS length: {len(css)}, Final HTML length: {len(html)}"
)
else:
current_app.logger.error(
f"[PDF_PREVIEW] Quote CSS validation FAILED - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}"
)
# Helper: remove @page rules from HTML inline styles when separate CSS exists
# This matches the fix used in PDF exports to avoid conflicts with WeasyPrint
def remove_page_rule_from_html(html_text):
"""Remove @page rules from HTML inline styles to avoid conflicts with separate CSS"""
import re
def remove_from_style_tag(match):
style_content = match.group(2)
# Remove @page rule from style content
# Need to handle nested @bottom-center rules properly
# Match @page { ... } including any nested rules
brace_count = 0
page_pattern = r"@page\s*\{"
page_match = re.search(page_pattern, style_content, re.IGNORECASE)
if page_match:
start = page_match.start()
# Find matching closing brace
end = len(style_content)
for i in range(page_match.end() - 1, len(style_content)):
if style_content[i] == "{":
brace_count += 1
elif style_content[i] == "}":
brace_count -= 1
if brace_count == 0:
end = i + 1
break
# Remove the @page rule
style_content = style_content[:start] + style_content[end:]
# Clean up any double newlines or extra whitespace
style_content = re.sub(r"\n\s*\n", "\n", style_content)
return f"{match.group(1)}{style_content}{match.group(3)}"
# Match <style> tags and remove @page rules from them
style_pattern = r"(<style[^>]*>)(.*?)(</style>)"
if re.search(style_pattern, html_text, re.IGNORECASE | re.DOTALL):
html_text = re.sub(style_pattern, remove_from_style_tag, html_text, flags=re.IGNORECASE | re.DOTALL)
return html_text
# Apply @page rule removal fix: if we have separate CSS and HTML with inline styles,
# remove @page rules from HTML to ensure the separate CSS @page rule is used
html_has_inline_styles = html and "<style>" in html
if html_has_inline_styles and css and css.strip():
# Check if HTML has @page rules
import re
html_page_rules = re.findall(r"@page\s*\{[^}]*\}", html, re.IGNORECASE | re.DOTALL)
if html_page_rules:
current_app.logger.debug(
f"PDF preview: Found {len(html_page_rules)} @page rule(s) in HTML inline styles - removing them"
)
# Remove @page rules from HTML inline styles (keep everything else)
html = remove_page_rule_from_html(html)
current_app.logger.debug("PDF preview: Removed @page rules from HTML inline styles")
# Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor
def _sanitize_jinja_blocks(raw: str) -> str:
try:
import html as _html
import re as _re
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:
# Use DD.MM.YYYY format for invoices and quotes
return value.strftime("%d.%m.%Y") if value else ""
except Exception:
return str(value) if value else ""
def _format_money(value):
try:
return f"{float(value):,.2f} {settings_obj.currency}"
except Exception:
return f"{value} {settings_obj.currency}"
# Helper function for logo - converts to base64 data URI
def _get_logo_base64(logo_path):
try:
if not logo_path or not os.path.exists(logo_path):
return None
import base64
import mimetypes
with open(logo_path, "rb") as f:
data = base64.b64encode(f.read()).decode("utf-8")
mime_type, _ = mimetypes.guess_type(logo_path)
if not mime_type:
mime_type = "image/png"
return f"data:{mime_type};base64,{data}"
except Exception as e:
print(f"Error loading logo: {e}")
return None
current_app.logger.info(
f"[PDF_PREVIEW] Rendering quote template string - PageSize: '{page_size}', QuoteID: {quote_id}, Sanitized HTML length: {len(sanitized)}"
)
body_html = render_sandboxed_string(
sanitized,
autoescape=True,
quote=quote,
settings=settings_obj,
Path=_Path,
format_date=_format_date,
format_money=_format_money,
get_logo_base64=_get_logo_base64,
item=sample_item,
)
current_app.logger.info(
f"[PDF_PREVIEW] Quote template rendered successfully - PageSize: '{page_size}', Rendered HTML length: {len(body_html)}"
)
except Exception as e:
import traceback
error_details = traceback.format_exc()
current_app.logger.error(
f"[PDF_PREVIEW] Quote template render error - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}",
exc_info=True,
)
body_html = (
f"<div style='color:red; padding:20px; border:2px solid red; margin:20px;'><h3>Template error:</h3><pre>{str(e)}</pre><pre>{error_details}</pre></div>"
+ sanitized
)
# Get page dimensions for preview styling
from app.models import QuotePDFTemplate
page_dimensions = QuotePDFTemplate.PAGE_SIZES.get(page_size, QuotePDFTemplate.PAGE_SIZES["A4"])
page_width_mm = page_dimensions["width"]
page_height_mm = page_dimensions["height"]
# Convert mm to pixels at 96 DPI (standard browser DPI for PDF preview)
# 1 inch = 25.4mm, 96 DPI = 96 pixels per inch
# Account for margins (typically 20mm = ~75px at 96 DPI)
margin_px = int((20 / 25.4) * 96) # 20mm margin in pixels
# Don't subtract margins from page dimensions - margins are applied to content, not page size
page_width_px = int((page_width_mm / 25.4) * 96)
page_height_px = int((page_height_mm / 25.4) * 96)
# Build complete HTML page with embedded styles
# For preview, scale to fit viewport while maintaining aspect ratio
page_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quote Preview ({page_size})</title>
<style>{css}
/* Preview-specific styles - completely new approach */
* {{
box-sizing: border-box;
margin: 0;
padding: 0;
}}
html {{
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
overflow: auto !important;
overflow-x: auto !important;
overflow-y: auto !important;
background-color: #e5e7eb !important;
}}
body {{
width: 100% !important;
min-width: 100% !important;
margin: 0 !important;
padding: 20px !important;
padding-top: 20px !important;
overflow: auto !important;
overflow-x: auto !important;
overflow-y: auto !important;
background-color: #e5e7eb !important;
display: flex !important;
align-items: flex-start !important;
justify-content: center !important;
box-sizing: border-box !important;
min-height: 100vh !important;
}}
.preview-container {{
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
border-radius: 8px;
overflow: visible;
position: relative;
width: {page_width_px}px;
min-width: {page_width_px}px;
max-width: {page_width_px}px;
display: flex;
align-items: flex-start;
justify-content: center;
transition: transform 0.2s ease;
transform-origin: top center;
margin-top: 20px;
}}
.preview-container .invoice-wrapper,
.preview-container .quote-wrapper {{
width: {page_width_px}px !important;
min-width: {page_width_px}px !important;
max-width: {page_width_px}px !important;
height: {page_height_px}px !important;
min-height: {page_height_px}px !important;
max-height: {page_height_px}px !important;
box-sizing: border-box !important;
overflow: visible !important;
margin: 0 auto !important;
padding: 0 !important;
background: transparent !important;
position: relative;
/* CSS zoom on container will scale this proportionally */
}}
</style>
<!-- Zoom is now controlled by the parent iframe's JavaScript -->
<script>
// Minimal script - zoom is handled by parent window
(function() {{
// Ensure container maintains correct dimensions
const container = document.querySelector('.preview-container');
if (container) {{
container.style.width = {page_width_px} + 'px';
container.style.minWidth = {page_width_px} + 'px';
container.style.maxWidth = {page_width_px} + 'px';
}}
}})();
</script>
</head>
<body>
<div class="preview-container">
{body_html}
</div>
</body>
</html>"""
current_app.logger.info(
f"[PDF_PREVIEW] Returning quote preview HTML - PageSize: '{page_size}', Total HTML length: {len(page_html)}, PageWidth: {page_width_px}px, PageHeight: {page_height_px}px, User: {current_user.username}"
)
return page_html
@admin_bp.route("/admin/upload-logo", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def upload_logo():
"""Upload company logo"""
if "logo" not in request.files:
flash(_("No logo file selected"), "error")
return redirect(url_for("admin.settings"))
file = request.files["logo"]
if file.filename == "":
flash(_("No logo file selected"), "error")
return redirect(url_for("admin.settings"))
if file and allowed_logo_file(file.filename):
# Generate unique filename
file_extension = file.filename.rsplit(".", 1)[1].lower()
unique_filename = f"company_logo_{uuid.uuid4().hex[:8]}.{file_extension}"
# Basic server-side validation: verify image type
try:
from PIL import Image
file.stream.seek(0)
img = Image.open(file.stream)
img.verify()
file.stream.seek(0)
except Exception:
flash(_("Invalid image file."), "error")
return redirect(url_for("admin.settings"))
# Save file
upload_folder = get_upload_folder()
file_path = os.path.join(upload_folder, unique_filename)
file.save(file_path)
# Log successful save
current_app.logger.info(f"Logo saved successfully: {file_path}")
current_app.logger.info(f"File exists check: {os.path.exists(file_path)}")
current_app.logger.info(
f'File size: {os.path.getsize(file_path) if os.path.exists(file_path) else "N/A"} bytes'
)
# Update settings
settings_obj = Settings.get_settings()
# Remove old logo if it exists
if settings_obj.company_logo_filename:
old_logo_path = os.path.join(upload_folder, settings_obj.company_logo_filename)
if os.path.exists(old_logo_path):
try:
os.remove(old_logo_path)
except OSError:
pass # Ignore errors when removing old file
settings_obj.company_logo_filename = unique_filename
if not safe_commit("admin_upload_logo"):
flash(_("Could not save logo due to a database error. Please check server logs."), "error")
return redirect(url_for("admin.settings"))
flash(
_(
'Company logo uploaded successfully! You can see it in the "Current Company Logo" section above. It will appear on invoices and PDF documents.'
),
"success",
)
else:
flash(_("Invalid file type. Allowed types: PNG, JPG, JPEG, GIF, SVG, WEBP"), "error")
return redirect(url_for("admin.settings"))
@admin_bp.route("/admin/remove-logo", methods=["POST"])
@login_required
@admin_or_permission_required("manage_settings")
def remove_logo():
"""Remove company logo"""
settings_obj = Settings.get_settings()
if settings_obj.company_logo_filename:
# Remove file from filesystem
logo_path = settings_obj.get_logo_path()
if logo_path and os.path.exists(logo_path):
try:
os.remove(logo_path)
except OSError:
pass # Ignore errors when removing file
# Clear filename from database
settings_obj.company_logo_filename = ""
if not safe_commit("admin_remove_logo"):
flash(_("Could not remove logo due to a database error. Please check server logs."), "error")
return redirect(url_for("admin.settings"))
flash(_("Company logo removed successfully. Upload a new logo in the section below if needed."), "success")
else:
flash(_("No logo to remove"), "info")
return redirect(url_for("admin.settings"))
@admin_bp.route("/admin/template-image/upload", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def upload_template_image():
"""Upload an image for use in PDF templates"""
import os
from datetime import datetime
from flask import url_for
from werkzeug.utils import secure_filename
# File upload configuration - only images
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
UPLOAD_FOLDER = "app/static/uploads/template_images"
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
def allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
if "file" not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files["file"]
if file.filename == "":
return jsonify({"error": "No file selected"}), 400
if not allowed_file(file.filename):
return jsonify({"error": "File type not allowed. Only images (PNG, JPG, JPEG, GIF, WEBP) are allowed"}), 400
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
return jsonify({"error": f"File size exceeds maximum allowed size ({MAX_FILE_SIZE / (1024*1024):.0f} MB)"}), 400
# Save file
original_filename = secure_filename(file.filename)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"template_{timestamp}_{original_filename}"
# Ensure upload directory exists
upload_dir = os.path.join(current_app.root_path, "..", UPLOAD_FOLDER)
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, filename)
file.save(file_path)
# Return URL for the image
image_url = url_for("admin.serve_template_image", filename=filename)
return jsonify({"success": True, "image_url": image_url, "filename": filename})
@admin_bp.route("/uploads/template_images/<path:filename>")
def serve_template_image(filename):
"""Serve uploaded template images (public route so images can be embedded in PDFs)"""
import os
from flask import send_from_directory
upload_folder = os.path.join(current_app.root_path, "..", "app/static/uploads/template_images")
return send_from_directory(upload_folder, filename)
# Public route to serve uploaded logos from the static uploads directory
@admin_bp.route("/uploads/logos/<path:filename>")
def serve_uploaded_logo(filename):
"""Serve company logo files stored under static/uploads/logos.
This route is intentionally public so logos render on unauthenticated pages
like the login screen and in favicons.
"""
try:
upload_folder = get_upload_folder()
file_path = os.path.join(upload_folder, filename)
if not os.path.exists(file_path):
current_app.logger.error(f"Logo file not found: {file_path}")
return "Logo file not found", 404
return send_from_directory(upload_folder, filename)
except Exception as e:
current_app.logger.error(f"Error serving logo {filename}: {str(e)}")
return "Error serving logo", 500
@admin_bp.route("/admin/backups")
@login_required
@admin_or_permission_required("manage_backups")
def backups_management():
"""Backups management page"""
# Get list of existing backups
backups_dir = get_backup_root_dir(current_app)
backups = []
if os.path.exists(backups_dir):
for filename in os.listdir(backups_dir):
if filename.endswith(".zip") and not filename.startswith("restore_"):
filepath = os.path.join(backups_dir, filename)
stat = os.stat(filepath)
backups.append(
{
"filename": filename,
"size": stat.st_size,
"created": datetime.fromtimestamp(stat.st_mtime),
"size_mb": round(stat.st_size / (1024 * 1024), 2),
}
)
# Sort by creation date (newest first)
backups.sort(key=lambda x: x["created"], reverse=True)
return render_template("admin/backups.html", backups=backups, backups_dir=backups_dir)
@admin_bp.route("/admin/backup/create", methods=["POST"])
@login_required
@admin_or_permission_required("manage_backups")
def create_backup_manual():
"""Create manual backup and return the archive for download."""
try:
archive_path = create_backup(current_app)
if not archive_path or not os.path.exists(archive_path):
flash(_("Backup failed: archive not created"), "error")
return redirect(url_for("admin.backups_management"))
# Stream file to user
return send_file(archive_path, as_attachment=True)
except Exception as e:
flash(_("Backup failed: %(error)s", error=str(e)), "error")
return redirect(url_for("admin.backups_management"))
@admin_bp.route("/admin/backup/download/<filename>")
@login_required
@admin_or_permission_required("manage_backups")
def download_backup(filename):
"""Download an existing backup file"""
# Security: only allow downloading .zip files, no path traversal
filename = secure_filename(filename)
if not filename.endswith(".zip"):
flash(_("Invalid file type"), "error")
return redirect(url_for("admin.backups_management"))
backups_dir = get_backup_root_dir(current_app)
filepath = os.path.join(backups_dir, filename)
if not os.path.exists(filepath):
flash(_("Backup file not found"), "error")
return redirect(url_for("admin.backups_management"))
return send_file(filepath, as_attachment=True)
@admin_bp.route("/admin/backup/delete/<filename>", methods=["POST"])
@login_required
@admin_or_permission_required("manage_backups")
def delete_backup(filename):
"""Delete a backup file"""
# Security: only allow deleting .zip files, no path traversal
filename = secure_filename(filename)
if not filename.endswith(".zip"):
flash(_("Invalid file type"), "error")
return redirect(url_for("admin.backups_management"))
backups_dir = get_backup_root_dir(current_app)
filepath = os.path.join(backups_dir, filename)
try:
if os.path.exists(filepath):
os.remove(filepath)
flash(_('Backup "%(filename)s" deleted successfully', filename=filename), "success")
else:
flash(_("Backup file not found"), "error")
except Exception as e:
flash(_("Failed to delete backup: %(error)s", error=str(e)), "error")
return redirect(url_for("admin.backups_management"))
@admin_bp.route("/admin/restore", methods=["GET", "POST"])
@admin_bp.route("/admin/restore/<filename>", methods=["POST"])
@limiter.limit("3 per minute", methods=["POST"]) # heavy operation
@login_required
@admin_or_permission_required("manage_backups")
def restore(filename=None):
"""Restore from an uploaded backup archive or existing backup file."""
if request.method == "POST":
backups_dir = get_backup_root_dir(current_app)
# If restoring from an existing backup file
if filename:
filename = secure_filename(filename)
if not filename.lower().endswith(".zip"):
flash(_("Invalid file type. Please select a .zip backup archive."), "error")
return redirect(url_for("admin.backups_management"))
temp_path = os.path.join(backups_dir, filename)
if not os.path.exists(temp_path):
flash(_("Backup file not found."), "error")
return redirect(url_for("admin.backups_management"))
# Copy to temp location for processing
actual_restore_path = os.path.join(backups_dir, f"restore_{uuid.uuid4().hex[:8]}_{filename}")
shutil.copy2(temp_path, actual_restore_path)
temp_path = actual_restore_path
# If uploading a new backup file
elif "backup_file" in request.files and request.files["backup_file"].filename != "":
file = request.files["backup_file"]
uploaded_filename = secure_filename(file.filename)
if not uploaded_filename.lower().endswith(".zip"):
flash(_("Invalid file type. Please upload a .zip backup archive."), "error")
return redirect(url_for("admin.restore"))
# Save temporarily under project backups
os.makedirs(backups_dir, exist_ok=True)
temp_path = os.path.join(backups_dir, f"restore_{uuid.uuid4().hex[:8]}_{uploaded_filename}")
file.save(temp_path)
else:
flash(_("No backup file provided"), "error")
return redirect(url_for("admin.restore"))
# Initialize progress state
token = uuid.uuid4().hex[:8]
RESTORE_PROGRESS[token] = {"status": "starting", "percent": 0, "message": "Queued"}
def progress_cb(label, percent):
RESTORE_PROGRESS[token] = {"status": "running", "percent": int(percent), "message": label}
# Capture the real Flask app object for use in a background thread
app_obj = current_app._get_current_object()
def _do_restore():
try:
RESTORE_PROGRESS[token] = {"status": "running", "percent": 5, "message": "Starting restore"}
success, message = restore_backup(app_obj, temp_path, progress_callback=progress_cb)
RESTORE_PROGRESS[token] = {
"status": "done" if success else "error",
"percent": 100 if success else RESTORE_PROGRESS[token].get("percent", 0),
"message": message,
}
except Exception as e:
RESTORE_PROGRESS[token] = {
"status": "error",
"percent": RESTORE_PROGRESS[token].get("percent", 0),
"message": str(e),
}
finally:
safe_file_remove(temp_path, app_obj.logger)
# Run restore in background to keep request responsive
t = threading.Thread(target=_do_restore, daemon=True)
t.start()
flash(_("Restore started. You can monitor progress on this page."), "info")
return redirect(url_for("admin.restore", token=token))
# GET
token = request.args.get("token")
progress = RESTORE_PROGRESS.get(token) if token else None
return render_template("admin/restore.html", progress=progress, token=token)
@admin_bp.route("/admin/system")
@login_required
@admin_or_permission_required("view_system_info")
def system_info():
"""Show system information"""
# Get system statistics
total_users = User.query.count()
total_projects = Project.query.count()
total_entries = TimeEntry.query.count()
active_timers = TimeEntry.query.filter_by(end_time=None).count()
# Get database size
db_size_bytes = 0
try:
engine = db.session.bind
dialect = engine.dialect.name if engine else ""
if dialect == "sqlite":
db_size_bytes = (
db.session.execute(
text("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()")
).scalar()
or 0
)
elif dialect in ("postgresql", "postgres"):
db_size_bytes = db.session.execute(text("SELECT pg_database_size(current_database())")).scalar() or 0
else:
db_size_bytes = 0
except Exception:
db_size_bytes = 0
db_size_mb = round(db_size_bytes / (1024 * 1024), 2) if db_size_bytes else 0
return render_template(
"admin/system_info.html",
total_users=total_users,
total_projects=total_projects,
total_entries=total_entries,
active_timers=active_timers,
db_size_mb=db_size_mb,
)
@admin_bp.route("/admin/oidc/debug")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_debug():
"""OIDC Configuration Debug Dashboard"""
from app import oauth
from app.config import Config
# Gather OIDC configuration
oidc_config = {
"enabled": False,
"auth_method": getattr(Config, "AUTH_METHOD", "local"),
"issuer": getattr(Config, "OIDC_ISSUER", None),
"client_id": getattr(Config, "OIDC_CLIENT_ID", None),
"client_secret_set": bool(getattr(Config, "OIDC_CLIENT_SECRET", None)),
"redirect_uri": getattr(Config, "OIDC_REDIRECT_URI", None),
"scopes": getattr(Config, "OIDC_SCOPES", "openid profile email"),
"username_claim": getattr(Config, "OIDC_USERNAME_CLAIM", "preferred_username"),
"email_claim": getattr(Config, "OIDC_EMAIL_CLAIM", "email"),
"full_name_claim": getattr(Config, "OIDC_FULL_NAME_CLAIM", "name"),
"groups_claim": getattr(Config, "OIDC_GROUPS_CLAIM", "groups"),
"admin_group": getattr(Config, "OIDC_ADMIN_GROUP", None),
"admin_emails": getattr(Config, "OIDC_ADMIN_EMAILS", []),
"post_logout_redirect": getattr(Config, "OIDC_POST_LOGOUT_REDIRECT_URI", None),
}
# Check if OIDC is enabled
auth_method = normalize_auth_method(oidc_config["auth_method"] or "local")
oidc_config["enabled"] = auth_includes_oidc(auth_method)
# Try to get OIDC client metadata
metadata = None
metadata_error = None
well_known_url = None
if oidc_config["enabled"] and oidc_config["issuer"]:
try:
client = oauth.create_client("oidc")
if client:
metadata = client.load_server_metadata()
well_known_url = f"{oidc_config['issuer'].rstrip('/')}/.well-known/openid-configuration"
except Exception as e:
metadata_error = str(e)
well_known_url = (
f"{oidc_config['issuer'].rstrip('/')}/.well-known/openid-configuration"
if oidc_config["issuer"]
else None
)
# Get OIDC users from database
oidc_users = []
try:
oidc_users = (
User.query.filter(User.oidc_issuer.isnot(None), User.oidc_sub.isnot(None))
.order_by(User.last_login.desc())
.all()
)
except Exception as e:
safe_log(current_app.logger, "debug", "OIDC users query failed (columns may not exist): %s", e)
return render_template(
"admin/oidc_debug.html",
oidc_config=oidc_config,
metadata=metadata,
metadata_error=metadata_error,
well_known_url=well_known_url,
oidc_users=oidc_users,
)
@admin_bp.route("/admin/oidc/test")
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_test():
"""Test OIDC configuration by fetching discovery document with enhanced DNS testing"""
from urllib.parse import urlparse
from app import oauth
from app.config import Config
from app.utils.oidc_metadata import (
detect_docker_environment,
fetch_oidc_metadata,
resolve_hostname_multiple_strategies,
test_dns_resolution,
)
auth_method = normalize_auth_method(getattr(Config, "AUTH_METHOD", "local"))
if not auth_includes_oidc(auth_method):
flash(_('OIDC is not enabled. Set AUTH_METHOD to "oidc", "both", or "all".'), "warning")
return redirect(url_for("admin.oidc_debug"))
issuer = getattr(Config, "OIDC_ISSUER", None)
if not issuer:
flash(_("OIDC_ISSUER is not configured"), "error")
return redirect(url_for("admin.oidc_debug"))
# Parse hostname
try:
parsed = urlparse(issuer)
hostname = parsed.netloc.split(":")[0]
except Exception as e:
flash(_("✗ Failed to parse issuer URL: %(error)s", error=str(e)), "error")
return redirect(url_for("admin.oidc_debug"))
# Test 1: Test DNS resolution with multiple strategies
flash(_("Testing DNS resolution with multiple strategies..."), "info")
dns_strategy = current_app.config.get("OIDC_DNS_RESOLUTION_STRATEGY", "auto")
# Test all strategies
strategies_to_test = (
["socket", "getaddrinfo"] if dns_strategy == "auto" or dns_strategy == "both" else [dns_strategy]
)
dns_results = {}
for strategy in strategies_to_test:
success, ip, error, strategy_used = resolve_hostname_multiple_strategies(
hostname, timeout=5, strategy=strategy, use_cache=False
)
dns_results[strategy] = {
"success": success,
"ip": ip,
"error": error,
"strategy_used": strategy_used,
}
if success:
# Mask IP for display (show only first octet)
masked_ip = ip.split(".")[0] + ".xxx.xxx.xxx" if ip and "." in ip else "N/A"
flash(
_("✓ DNS resolution successful using %(strategy)s strategy: %(ip)s", strategy=strategy, ip=masked_ip),
"success",
)
else:
flash(
_(
"✗ DNS resolution failed using %(strategy)s strategy: %(error)s",
strategy=strategy,
error=error or "Unknown error",
),
"warning",
)
# Check Docker environment
if detect_docker_environment():
flash(_(" Docker environment detected - internal service names may be available"), "info")
# Test 2: Fetch discovery document using enhanced metadata fetcher
well_known_url = f"{issuer.rstrip('/')}/.well-known/openid-configuration"
use_ip_directly = current_app.config.get("OIDC_USE_IP_DIRECTLY", True)
use_docker_internal = current_app.config.get("OIDC_USE_DOCKER_INTERNAL", True)
max_retries = int(current_app.config.get("OIDC_METADATA_RETRY_ATTEMPTS", 3))
timeout = int(current_app.config.get("OIDC_METADATA_FETCH_TIMEOUT", 10))
try:
current_app.logger.info("OIDC Test: Fetching discovery document from %s", well_known_url)
metadata, metadata_error, diagnostics = fetch_oidc_metadata(
issuer,
max_retries=max_retries,
retry_delay=2,
timeout=timeout,
use_dns_test=True,
dns_strategy=dns_strategy,
use_ip_directly=use_ip_directly,
use_docker_internal=use_docker_internal,
)
if metadata:
discovery_doc = metadata
flash(_("✓ Discovery document fetched successfully from %(url)s", url=well_known_url), "success")
if diagnostics:
dns_info = diagnostics.get("dns_resolution", {})
strategy_used = dns_info.get("strategy", "unknown")
flash(
_("✓ DNS strategy used: %(strategy)s", strategy=strategy_used),
"info",
)
current_app.logger.info("OIDC Test: Discovery document retrieved, issuer=%s", discovery_doc.get("issuer"))
else:
flash(
_("✗ Failed to fetch discovery document: %(error)s", error=metadata_error or "Unknown error"), "error"
)
current_app.logger.error("OIDC Test: Failed to fetch discovery document: %s", metadata_error)
return redirect(url_for("admin.oidc_debug"))
except Exception as e:
flash(_("✗ Unexpected error: %(error)s", error=str(e)), "error")
current_app.logger.error("OIDC Test: Unexpected error: %s", str(e))
return redirect(url_for("admin.oidc_debug"))
# Ensure discovery_doc is defined
if "discovery_doc" not in locals():
flash(_("✗ Failed to retrieve discovery document"), "error")
return redirect(url_for("admin.oidc_debug"))
# Test 2: Check if OAuth client is registered
try:
client = oauth.create_client("oidc")
if client:
flash(_("✓ OAuth client is registered in application"), "success")
current_app.logger.info("OIDC Test: OAuth client registered")
else:
flash(_("✗ OAuth client is not registered"), "error")
current_app.logger.error("OIDC Test: OAuth client not registered")
except Exception as e:
flash(_("✗ Failed to create OAuth client: %(error)s", error=str(e)), "error")
current_app.logger.error("OIDC Test: Failed to create OAuth client: %s", str(e))
# Test 3: Verify required endpoints are present
required_endpoints = ["authorization_endpoint", "token_endpoint", "userinfo_endpoint"]
for endpoint in required_endpoints:
if endpoint in discovery_doc:
flash(_("%(endpoint)s: %(url)s", endpoint=endpoint, url=discovery_doc[endpoint]), "info")
else:
flash(_("✗ Missing %(endpoint)s in discovery document", endpoint=endpoint), "warning")
# Test 4: Check supported scopes
supported_scopes = discovery_doc.get("scopes_supported", [])
requested_scopes = getattr(Config, "OIDC_SCOPES", "openid profile email").split()
for scope in requested_scopes:
if scope in supported_scopes:
flash(_('✓ Scope "%(scope)s" is supported by provider', scope=scope), "info")
else:
flash(
_(
'⚠ Scope "%(scope)s" may not be supported by provider (supported: %(supported)s)',
scope=scope,
supported=", ".join(supported_scopes),
),
"warning",
)
# Test 5: Check claims
supported_claims = discovery_doc.get("claims_supported", [])
if supported_claims:
flash(_(" Provider supports claims: %(claims)s", claims=", ".join(supported_claims)), "info")
# Check if configured claims are supported
claim_checks = {
"username": getattr(Config, "OIDC_USERNAME_CLAIM", "preferred_username"),
"email": getattr(Config, "OIDC_EMAIL_CLAIM", "email"),
"full_name": getattr(Config, "OIDC_FULL_NAME_CLAIM", "name"),
"groups": getattr(Config, "OIDC_GROUPS_CLAIM", "groups"),
}
for claim_type, claim_name in claim_checks.items():
if claim_name in supported_claims:
flash(
_(
'✓ Configured %(claim_type)s claim "%(claim_name)s" is supported',
claim_type=claim_type,
claim_name=claim_name,
),
"info",
)
else:
flash(
_(
'⚠ Configured %(claim_type)s claim "%(claim_name)s" not in supported claims list (may still work)',
claim_type=claim_type,
claim_name=claim_name,
),
"warning",
)
flash(_("OIDC configuration test completed"), "info")
return redirect(url_for("admin.oidc_debug"))
@admin_bp.route("/admin/oidc/user/<int:user_id>")
@login_required
@admin_or_permission_required("view_users")
def oidc_user_detail(user_id):
"""View OIDC details for a specific user"""
user = User.query.get_or_404(user_id)
return render_template("admin/oidc_user_detail.html", user=user)
# ==================== OIDC Setup Wizard ====================
@admin_bp.route("/admin/oidc/setup-wizard")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_setup_wizard():
"""Guided OIDC setup wizard"""
from app.config import Config
# Get current configuration if any
current_config = {
"auth_method": getattr(Config, "AUTH_METHOD", "local"),
"issuer": getattr(Config, "OIDC_ISSUER", ""),
"client_id": getattr(Config, "OIDC_CLIENT_ID", ""),
"client_secret_set": bool(getattr(Config, "OIDC_CLIENT_SECRET", None)),
"redirect_uri": getattr(Config, "OIDC_REDIRECT_URI", ""),
"scopes": getattr(Config, "OIDC_SCOPES", "openid profile email"),
"username_claim": getattr(Config, "OIDC_USERNAME_CLAIM", "preferred_username"),
"email_claim": getattr(Config, "OIDC_EMAIL_CLAIM", "email"),
"full_name_claim": getattr(Config, "OIDC_FULL_NAME_CLAIM", "name"),
"groups_claim": getattr(Config, "OIDC_GROUPS_CLAIM", "groups"),
"admin_group": getattr(Config, "OIDC_ADMIN_GROUP", ""),
"admin_emails": ",".join(getattr(Config, "OIDC_ADMIN_EMAILS", [])),
"post_logout_redirect": getattr(Config, "OIDC_POST_LOGOUT_REDIRECT_URI", ""),
}
# Generate redirect URI if not set
if not current_config["redirect_uri"]:
current_config["redirect_uri"] = url_for("auth.oidc_callback", _external=True)
return render_template("admin/oidc_setup_wizard.html", current_config=current_config)
@admin_bp.route("/admin/oidc/setup-wizard/test-connection", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_wizard_test_connection():
"""Test DNS resolution and metadata fetch for OIDC issuer"""
from urllib.parse import urlparse
from app.utils.oidc_metadata import fetch_oidc_metadata, resolve_hostname_multiple_strategies, test_dns_resolution
data = request.get_json() or {}
issuer = data.get("issuer", "").strip()
if not issuer:
return jsonify({"success": False, "error": "Issuer URL is required"}), 400
# Validate URL format
try:
parsed = urlparse(issuer)
if not parsed.scheme or not parsed.netloc:
return jsonify({"success": False, "error": "Invalid URL format"}), 400
hostname = parsed.netloc.split(":")[0]
except Exception as e:
return jsonify({"success": False, "error": f"Invalid URL: {str(e)}"}), 400
result = {
"success": False,
"dns_resolved": False,
"metadata": None,
"error": None,
"hostname": hostname,
}
# Test DNS resolution with multiple strategies
dns_strategy = current_app.config.get("OIDC_DNS_RESOLUTION_STRATEGY", "auto")
dns_success, dns_ip, dns_error, dns_strategy_used = test_dns_resolution(hostname, timeout=5, strategy=dns_strategy)
result["dns_resolved"] = dns_success
result["dns_strategy"] = dns_strategy_used
result["dns_ip"] = dns_ip # Will be masked in response
if not dns_success:
result["error"] = dns_error
return jsonify(result), 200 # Return 200 but with success=False
# Fetch metadata
use_ip_directly = current_app.config.get("OIDC_USE_IP_DIRECTLY", True)
use_docker_internal = current_app.config.get("OIDC_USE_DOCKER_INTERNAL", True)
metadata, metadata_error, diagnostics = fetch_oidc_metadata(
issuer,
max_retries=3,
retry_delay=2,
timeout=10,
use_dns_test=False, # Already tested DNS
dns_strategy=dns_strategy,
use_ip_directly=use_ip_directly,
use_docker_internal=use_docker_internal,
)
if diagnostics:
result["diagnostics"] = diagnostics
if metadata:
result["success"] = True
result["metadata"] = metadata
else:
result["error"] = metadata_error
return jsonify(result), 200
@admin_bp.route("/admin/oidc/setup-wizard/validate-config", methods=["POST"])
@limiter.limit("20 per minute")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_wizard_validate_config():
"""Validate OIDC configuration"""
from urllib.parse import urlparse
data = request.get_json() or {}
errors = []
# Validate issuer
issuer = data.get("issuer", "").strip()
if not issuer:
errors.append({"field": "issuer", "message": "Issuer URL is required"})
else:
try:
parsed = urlparse(issuer)
if not parsed.scheme or not parsed.netloc:
errors.append({"field": "issuer", "message": "Invalid URL format"})
elif parsed.scheme not in ("http", "https"):
errors.append({"field": "issuer", "message": "URL must use http or https"})
except Exception as e:
errors.append({"field": "issuer", "message": f"Invalid URL: {str(e)}"})
# Validate client ID
if not data.get("client_id", "").strip():
errors.append({"field": "client_id", "message": "Client ID is required"})
# Validate client secret
if not data.get("client_secret", "").strip():
errors.append({"field": "client_secret", "message": "Client Secret is required"})
# Validate auth method
auth_method = normalize_auth_method(data.get("auth_method", ""))
if not auth_includes_oidc(auth_method):
errors.append({"field": "auth_method", "message": "Auth method must be 'oidc', 'both', or 'all'"})
# Validate redirect URI if provided
redirect_uri = data.get("redirect_uri", "").strip()
if redirect_uri:
try:
parsed = urlparse(redirect_uri)
if not parsed.scheme or not parsed.netloc:
errors.append({"field": "redirect_uri", "message": "Invalid redirect URI format"})
except Exception as e:
errors.append({"field": "redirect_uri", "message": f"Invalid redirect URI: {str(e)}"})
if errors:
return jsonify({"valid": False, "errors": errors}), 200
return jsonify({"valid": True, "errors": []}), 200
@admin_bp.route("/admin/oidc/setup-wizard/generate-config", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_wizard_generate_config():
"""Generate environment variable configuration from wizard data"""
data = request.get_json() or {}
# Get base URL for redirect URI generation
base_url = request.host_url.rstrip("/")
if not data.get("redirect_uri"):
redirect_uri = f"{base_url}/auth/oidc/callback"
else:
redirect_uri = data.get("redirect_uri", "").strip()
# Build environment variables
env_vars = {
"AUTH_METHOD": data.get("auth_method", "oidc"),
"OIDC_ISSUER": data.get("issuer", ""),
"OIDC_CLIENT_ID": data.get("client_id", ""),
"OIDC_CLIENT_SECRET": data.get("client_secret", ""),
"OIDC_REDIRECT_URI": redirect_uri,
}
# Optional settings
if data.get("scopes"):
env_vars["OIDC_SCOPES"] = data.get("scopes")
if data.get("username_claim"):
env_vars["OIDC_USERNAME_CLAIM"] = data.get("username_claim")
if data.get("email_claim"):
env_vars["OIDC_EMAIL_CLAIM"] = data.get("email_claim")
if data.get("full_name_claim"):
env_vars["OIDC_FULL_NAME_CLAIM"] = data.get("full_name_claim")
if data.get("groups_claim"):
env_vars["OIDC_GROUPS_CLAIM"] = data.get("groups_claim")
if data.get("admin_group"):
env_vars["OIDC_ADMIN_GROUP"] = data.get("admin_group")
if data.get("admin_emails"):
env_vars["OIDC_ADMIN_EMAILS"] = data.get("admin_emails")
if data.get("post_logout_redirect"):
env_vars["OIDC_POST_LOGOUT_REDIRECT_URI"] = data.get("post_logout_redirect")
# Generate .env format
env_lines = []
for key, value in env_vars.items():
if value: # Only include non-empty values
# Escape special characters in value
if " " in str(value) or "#" in str(value) or "$" in str(value):
value = f'"{value}"'
env_lines.append(f"{key}={value}")
env_content = "\n".join(env_lines)
# Generate Docker Compose format
docker_compose_lines = [" # OIDC Configuration"]
for key, value in env_vars.items():
if value:
docker_compose_lines.append(f' - {key}="{value}"')
docker_compose_content = "\n".join(docker_compose_lines)
return (
jsonify(
{
"success": True,
"env_content": env_content,
"docker_compose_content": docker_compose_content,
"redirect_uri": redirect_uri,
}
),
200,
)
# ==================== LDAP Setup Wizard ====================
def _ldap_wizard_truthy(val) -> bool:
if isinstance(val, bool):
return val
if isinstance(val, (int, float)):
return bool(val)
s = str(val or "").strip().lower()
return s in ("1", "true", "yes", "y", "on")
def _ldap_wizard_int(val, default: int, *, lo: int | None = None, hi: int | None = None) -> int:
try:
n = int(val)
except (TypeError, ValueError):
n = default
if lo is not None:
n = max(lo, n)
if hi is not None:
n = min(hi, n)
return n
def _ldap_wizard_cfg_from_json(data: dict) -> dict[str, object]:
"""Map wizard JSON (LDAP_* keys) to a config-like dict for LDAPService.test_connection."""
return {
"LDAP_HOST": (data.get("LDAP_HOST") or "").strip() or "localhost",
"LDAP_PORT": _ldap_wizard_int(data.get("LDAP_PORT"), 389, lo=1, hi=65535),
"LDAP_USE_SSL": _ldap_wizard_truthy(data.get("LDAP_USE_SSL")),
"LDAP_USE_TLS": _ldap_wizard_truthy(data.get("LDAP_USE_TLS")),
"LDAP_BIND_DN": (data.get("LDAP_BIND_DN") or "").strip(),
"LDAP_BIND_PASSWORD": data.get("LDAP_BIND_PASSWORD") or "",
"LDAP_BASE_DN": (data.get("LDAP_BASE_DN") or "").strip(),
"LDAP_USER_DN": (data.get("LDAP_USER_DN") or "").strip(),
"LDAP_USER_OBJECT_CLASS": (data.get("LDAP_USER_OBJECT_CLASS") or "inetOrgPerson").strip() or "inetOrgPerson",
"LDAP_USER_LOGIN_ATTR": (data.get("LDAP_USER_LOGIN_ATTR") or "uid").strip() or "uid",
"LDAP_USER_EMAIL_ATTR": (data.get("LDAP_USER_EMAIL_ATTR") or "mail").strip() or "mail",
"LDAP_USER_FNAME_ATTR": (data.get("LDAP_USER_FNAME_ATTR") or "givenName").strip() or "givenName",
"LDAP_USER_LNAME_ATTR": (data.get("LDAP_USER_LNAME_ATTR") or "sn").strip() or "sn",
"LDAP_GROUP_DN": (data.get("LDAP_GROUP_DN") or "").strip(),
"LDAP_GROUP_OBJECT_CLASS": (data.get("LDAP_GROUP_OBJECT_CLASS") or "groupOfNames").strip() or "groupOfNames",
"LDAP_ADMIN_GROUP": (data.get("LDAP_ADMIN_GROUP") or "").strip(),
"LDAP_REQUIRED_GROUP": (data.get("LDAP_REQUIRED_GROUP") or "").strip(),
"LDAP_TLS_CA_CERT_FILE": (data.get("LDAP_TLS_CA_CERT_FILE") or "").strip(),
"LDAP_TIMEOUT": _ldap_wizard_int(data.get("LDAP_TIMEOUT"), 10, lo=1, hi=120),
}
def _ldap_wizard_escape_env_value(value: object) -> str:
s = "" if value is None else str(value)
if " " in s or "#" in s or "$" in s or "\n" in s:
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return s
@admin_bp.route("/admin/ldap/setup-wizard")
@login_required
@admin_or_permission_required("manage_settings")
def ldap_setup_wizard():
"""Guided LDAP setup wizard (env-based configuration)."""
from app.config import Config
auth_method = getattr(Config, "AUTH_METHOD", "local")
bind_secret = getattr(Config, "LDAP_BIND_PASSWORD", None) or ""
current_config = {
"auth_method": auth_method,
"LDAP_HOST": getattr(Config, "LDAP_HOST", "") or "",
"LDAP_PORT": getattr(Config, "LDAP_PORT", 389),
"LDAP_USE_SSL": bool(getattr(Config, "LDAP_USE_SSL", False)),
"LDAP_USE_TLS": bool(getattr(Config, "LDAP_USE_TLS", False)),
"LDAP_BIND_DN": getattr(Config, "LDAP_BIND_DN", "") or "",
"bind_password_set": bool(bind_secret),
"LDAP_BASE_DN": getattr(Config, "LDAP_BASE_DN", "") or "",
"LDAP_USER_DN": getattr(Config, "LDAP_USER_DN", "") or "",
"LDAP_USER_OBJECT_CLASS": getattr(Config, "LDAP_USER_OBJECT_CLASS", "") or "inetOrgPerson",
"LDAP_USER_LOGIN_ATTR": getattr(Config, "LDAP_USER_LOGIN_ATTR", "") or "uid",
"LDAP_USER_EMAIL_ATTR": getattr(Config, "LDAP_USER_EMAIL_ATTR", "") or "mail",
"LDAP_USER_FNAME_ATTR": getattr(Config, "LDAP_USER_FNAME_ATTR", "") or "givenName",
"LDAP_USER_LNAME_ATTR": getattr(Config, "LDAP_USER_LNAME_ATTR", "") or "sn",
"LDAP_GROUP_DN": getattr(Config, "LDAP_GROUP_DN", "") or "",
"LDAP_GROUP_OBJECT_CLASS": getattr(Config, "LDAP_GROUP_OBJECT_CLASS", "") or "groupOfNames",
"LDAP_ADMIN_GROUP": getattr(Config, "LDAP_ADMIN_GROUP", "") or "",
"LDAP_REQUIRED_GROUP": getattr(Config, "LDAP_REQUIRED_GROUP", "") or "",
"LDAP_TLS_CA_CERT_FILE": getattr(Config, "LDAP_TLS_CA_CERT_FILE", "") or "",
"LDAP_TIMEOUT": getattr(Config, "LDAP_TIMEOUT", 10),
}
return render_template("admin/ldap_setup_wizard.html", current_config=current_config)
@admin_bp.route("/admin/ldap/setup-wizard/test-connection", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def ldap_wizard_test_connection():
"""Test LDAP bind and user subtree using wizard-submitted values."""
from app.services.ldap_service import LDAPService
data = request.get_json() or {}
cfg = _ldap_wizard_cfg_from_json(data)
result = LDAPService.test_connection(cfg)
return jsonify(result), 200
@admin_bp.route("/admin/ldap/setup-wizard/validate-config", methods=["POST"])
@limiter.limit("20 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def ldap_wizard_validate_config():
"""Validate LDAP wizard fields before generating env output."""
data = request.get_json() or {}
errors = []
host = (data.get("LDAP_HOST") or "").strip()
if not host:
errors.append({"field": "LDAP_HOST", "message": "LDAP host is required"})
bind_dn = (data.get("LDAP_BIND_DN") or "").strip()
if not bind_dn:
errors.append({"field": "LDAP_BIND_DN", "message": "Bind DN is required"})
bind_pw = data.get("LDAP_BIND_PASSWORD")
if bind_pw is None or str(bind_pw).strip() == "":
errors.append({"field": "LDAP_BIND_PASSWORD", "message": "Bind password is required"})
base_dn = (data.get("LDAP_BASE_DN") or "").strip()
if not base_dn:
errors.append({"field": "LDAP_BASE_DN", "message": "Base DN is required"})
login_attr = (data.get("LDAP_USER_LOGIN_ATTR") or "").strip()
if not login_attr:
errors.append({"field": "LDAP_USER_LOGIN_ATTR", "message": "Login attribute is required"})
auth_method = normalize_auth_method(data.get("AUTH_METHOD", ""))
if not auth_includes_ldap(auth_method):
errors.append(
{
"field": "AUTH_METHOD",
"message": "Authentication method must be 'ldap' or 'all'",
}
)
port_raw = data.get("LDAP_PORT")
if port_raw not in (None, ""):
try:
p = int(port_raw)
if p < 1 or p > 65535:
errors.append({"field": "LDAP_PORT", "message": "Port must be between 1 and 65535"})
except (TypeError, ValueError):
errors.append({"field": "LDAP_PORT", "message": "Port must be a number"})
timeout_raw = data.get("LDAP_TIMEOUT")
if timeout_raw not in (None, ""):
try:
t = int(timeout_raw)
if t < 1 or t > 120:
errors.append({"field": "LDAP_TIMEOUT", "message": "Timeout must be between 1 and 120 seconds"})
except (TypeError, ValueError):
errors.append({"field": "LDAP_TIMEOUT", "message": "Timeout must be a number"})
if errors:
return jsonify({"valid": False, "errors": errors}), 200
return jsonify({"valid": True, "errors": []}), 200
@admin_bp.route("/admin/ldap/setup-wizard/generate-config", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def ldap_wizard_generate_config():
"""Generate .env and docker-compose style lines from wizard data."""
data = request.get_json() or {}
auth_method = normalize_auth_method(data.get("AUTH_METHOD", "ldap"))
if not auth_includes_ldap(auth_method):
return jsonify({"success": False, "error": "AUTH_METHOD must be 'ldap' or 'all'"}), 400
env_vars: dict[str, str] = {
"AUTH_METHOD": auth_method,
"LDAP_HOST": (data.get("LDAP_HOST") or "").strip(),
"LDAP_PORT": str(_ldap_wizard_int(data.get("LDAP_PORT"), 389, lo=1, hi=65535)),
"LDAP_USE_SSL": "true" if _ldap_wizard_truthy(data.get("LDAP_USE_SSL")) else "false",
"LDAP_USE_TLS": "true" if _ldap_wizard_truthy(data.get("LDAP_USE_TLS")) else "false",
"LDAP_BIND_DN": (data.get("LDAP_BIND_DN") or "").strip(),
"LDAP_BIND_PASSWORD": str(data.get("LDAP_BIND_PASSWORD") or ""),
"LDAP_BASE_DN": (data.get("LDAP_BASE_DN") or "").strip(),
"LDAP_USER_DN": (data.get("LDAP_USER_DN") or "").strip(),
"LDAP_USER_OBJECT_CLASS": (data.get("LDAP_USER_OBJECT_CLASS") or "inetOrgPerson").strip() or "inetOrgPerson",
"LDAP_USER_LOGIN_ATTR": (data.get("LDAP_USER_LOGIN_ATTR") or "uid").strip() or "uid",
"LDAP_USER_EMAIL_ATTR": (data.get("LDAP_USER_EMAIL_ATTR") or "mail").strip() or "mail",
"LDAP_USER_FNAME_ATTR": (data.get("LDAP_USER_FNAME_ATTR") or "givenName").strip() or "givenName",
"LDAP_USER_LNAME_ATTR": (data.get("LDAP_USER_LNAME_ATTR") or "sn").strip() or "sn",
"LDAP_GROUP_DN": (data.get("LDAP_GROUP_DN") or "").strip(),
"LDAP_GROUP_OBJECT_CLASS": (data.get("LDAP_GROUP_OBJECT_CLASS") or "groupOfNames").strip() or "groupOfNames",
"LDAP_ADMIN_GROUP": (data.get("LDAP_ADMIN_GROUP") or "").strip(),
"LDAP_REQUIRED_GROUP": (data.get("LDAP_REQUIRED_GROUP") or "").strip(),
"LDAP_TLS_CA_CERT_FILE": (data.get("LDAP_TLS_CA_CERT_FILE") or "").strip(),
"LDAP_TIMEOUT": str(_ldap_wizard_int(data.get("LDAP_TIMEOUT"), 10, lo=1, hi=120)),
}
for req_key in ("LDAP_HOST", "LDAP_BIND_DN", "LDAP_BIND_PASSWORD", "LDAP_BASE_DN"):
if not env_vars.get(req_key, "").strip():
return jsonify({"success": False, "error": f"{req_key} is required"}), 400
optional_skip_if_empty = frozenset(
{"LDAP_ADMIN_GROUP", "LDAP_REQUIRED_GROUP", "LDAP_TLS_CA_CERT_FILE", "LDAP_USER_DN"}
)
env_lines = []
for key, value in env_vars.items():
v = str(value)
if not v.strip() and key in optional_skip_if_empty:
continue
escaped = _ldap_wizard_escape_env_value(v)
env_lines.append(f"{key}={escaped}")
env_content = "\n".join(env_lines)
docker_compose_lines = [" # LDAP configuration"]
for key, value in env_vars.items():
v = str(value)
if not v.strip() and key in optional_skip_if_empty:
continue
dv = _ldap_wizard_escape_env_value(v)
docker_compose_lines.append(f" - {key}={dv}")
docker_compose_content = "\n".join(docker_compose_lines)
return (
jsonify(
{
"success": True,
"env_content": env_content,
"docker_compose_content": docker_compose_content,
}
),
200,
)
# ==================== API Token Management ====================
@admin_bp.route("/admin/api-tokens")
@login_required
@admin_or_permission_required("manage_api_tokens")
def api_tokens():
"""API tokens management page"""
from app.models import ApiToken
tokens = ApiToken.query.order_by(ApiToken.created_at.desc()).all()
users = User.query.filter_by(is_active=True).order_by(User.username).all()
return render_template("admin/api_tokens.html", tokens=tokens, users=users, now=datetime.utcnow())
@admin_bp.route("/admin/api-tokens", methods=["POST"])
@login_required
@admin_or_permission_required("manage_api_tokens")
def create_api_token():
"""Create a new API token"""
from app.models import ApiToken
data = request.get_json() or {}
# Validate input
if not data.get("name"):
return jsonify({"error": "Token name is required"}), 400
if not data.get("user_id"):
return jsonify({"error": "User ID is required"}), 400
if not data.get("scopes"):
return jsonify({"error": "At least one scope is required"}), 400
# Verify user exists
user = User.query.get(data["user_id"])
if not user:
return jsonify({"error": "User not found"}), 404
if not user:
return jsonify({"error": "Invalid user"}), 400
# Create token
try:
api_token, plain_token = ApiToken.create_token(
user_id=data["user_id"],
name=data["name"],
description=data.get("description", ""),
scopes=data["scopes"],
expires_days=data.get("expires_days"),
)
db.session.add(api_token)
db.session.commit()
current_app.logger.info(
f"API token '{data['name']}' created for user {user.username} by {current_user.username}"
)
return (
jsonify({"message": "API token created successfully", "token": plain_token, "token_id": api_token.id}),
201,
)
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Failed to create API token: {e}")
return jsonify({"error": "Failed to create token"}), 500
@admin_bp.route("/admin/api-tokens/<int:token_id>/toggle", methods=["POST"])
@login_required
@admin_or_permission_required("manage_api_tokens")
def toggle_api_token(token_id):
"""Toggle API token active status"""
from app.models import ApiToken
token = ApiToken.query.get_or_404(token_id)
token.is_active = not token.is_active
try:
db.session.commit()
status = "activated" if token.is_active else "deactivated"
current_app.logger.info(f"API token '{token.name}' {status} by {current_user.username}")
return jsonify({"message": f"Token {status} successfully"})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Failed to toggle API token: {e}")
return jsonify({"error": "Failed to update token"}), 500
@admin_bp.route("/admin/api-tokens/<int:token_id>", methods=["DELETE"])
@login_required
@admin_or_permission_required("manage_api_tokens")
def delete_api_token(token_id):
"""Delete an API token"""
from app.models import ApiToken
token = ApiToken.query.get_or_404(token_id)
token_name = token.name
try:
db.session.delete(token)
db.session.commit()
current_app.logger.info(f"API token '{token_name}' deleted by {current_user.username}")
return jsonify({"message": "Token deleted successfully"})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Failed to delete API token: {e}")
return jsonify({"error": "Failed to delete token"}), 500
# ==================== Email Configuration Management ====================
@admin_bp.route("/admin/email")
@login_required
@admin_or_permission_required("manage_settings")
def email_support():
"""Email configuration and testing page"""
from app.utils.email import check_email_configuration
# Get email configuration status
email_status = check_email_configuration()
# Log dashboard access
app_module.log_event("admin.email_support_viewed", user_id=current_user.id)
app_module.track_event(current_user.id, "admin.email_support_viewed", {})
return render_template("admin/email_support.html", email_status=email_status)
@admin_bp.route("/admin/email/test", methods=["POST"])
@limiter.limit("5 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def test_email():
"""Send a test email"""
from app.utils.email import send_test_email
data = request.get_json() or {}
recipient = data.get("recipient")
if not recipient:
current_app.logger.warning(f"[EMAIL TEST API] No recipient provided by user {current_user.username}")
return jsonify({"success": False, "message": "Recipient email is required"}), 400
current_app.logger.info(f"[EMAIL TEST API] Test email request from user {current_user.username} to {recipient}")
# Send test email
sender_name = current_user.username or "TimeTracker Admin"
success, message = send_test_email(recipient, sender_name)
# Log the test
current_app.logger.info(f"[EMAIL TEST API] Result: {'SUCCESS' if success else 'FAILED'} - {message}")
app_module.log_event("admin.email_test_sent", user_id=current_user.id, recipient=recipient, success=success)
app_module.track_event(current_user.id, "admin.email_test_sent", {"success": success, "configured": success})
if success:
return jsonify({"success": True, "message": message}), 200
else:
return jsonify({"success": False, "message": message}), 500
@admin_bp.route("/admin/email/config-status", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def email_config_status():
"""Get current email configuration status (for AJAX polling)"""
from app.utils.email import check_email_configuration
email_status = check_email_configuration()
return jsonify(email_status), 200
@admin_bp.route("/admin/email/configure", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def save_email_config():
"""Save email configuration to database"""
from app.utils.email import reload_mail_config
data = request.get_json() or {}
current_app.logger.info(f"[EMAIL CONFIG] Saving email configuration by user {current_user.username}")
# Get settings
settings = Settings.get_settings()
# Update email configuration
settings.mail_enabled = data.get("enabled", False)
settings.mail_server = data.get("server", "").strip()
settings.mail_port = int(data.get("port", 587))
settings.mail_use_tls = data.get("use_tls", True)
settings.mail_use_ssl = data.get("use_ssl", False)
settings.mail_username = data.get("username", "").strip()
# Only update password if provided (non-empty)
password = data.get("password", "").strip()
if password:
settings.set_secret("mail_password", password)
current_app.logger.info("[EMAIL CONFIG] Password updated")
settings.mail_default_sender = data.get("default_sender", "").strip()
test_recipient = data.get("test_recipient", "").strip()
if test_recipient and "@" not in test_recipient:
return jsonify({"success": False, "message": "Invalid test recipient email address"}), 400
settings.mail_test_recipient = test_recipient
current_app.logger.info(
f"[EMAIL CONFIG] Settings: enabled={settings.mail_enabled}, "
f"server={settings.mail_server}:{settings.mail_port}, "
f"tls={settings.mail_use_tls}, ssl={settings.mail_use_ssl}"
)
# Validate
if settings.mail_enabled and not settings.mail_server:
current_app.logger.warning("[EMAIL CONFIG] Validation failed: mail server required")
return jsonify({"success": False, "message": "Mail server is required when email is enabled"}), 400
if settings.mail_use_tls and settings.mail_use_ssl:
current_app.logger.warning("[EMAIL CONFIG] Validation failed: both TLS and SSL enabled")
return jsonify({"success": False, "message": "Cannot use both TLS and SSL. Please choose one."}), 400
# Save to database
if not safe_commit("admin_save_email_config"):
current_app.logger.error("[EMAIL CONFIG] Failed to save to database")
return jsonify({"success": False, "message": "Failed to save email configuration to database"}), 500
current_app.logger.info("[EMAIL CONFIG] ✓ Configuration saved to database")
# Reload mail configuration
if settings.mail_enabled:
current_app.logger.info("[EMAIL CONFIG] Reloading mail configuration...")
reload_result = reload_mail_config(current_app._get_current_object())
current_app.logger.info(f"[EMAIL CONFIG] Mail config reload: {'SUCCESS' if reload_result else 'FAILED'}")
# Log the change
app_module.log_event("admin.email_config_saved", user_id=current_user.id, enabled=settings.mail_enabled)
app_module.track_event(
current_user.id, "admin.email_config_saved", {"enabled": settings.mail_enabled, "source": "database"}
)
current_app.logger.info("[EMAIL CONFIG] ✓ Email configuration update complete")
return jsonify({"success": True, "message": "Email configuration saved successfully"}), 200
@admin_bp.route("/admin/email/get-config", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def get_email_config():
"""Get current email configuration from database"""
settings = Settings.get_settings()
return (
jsonify(
{
"enabled": settings.mail_enabled,
"server": settings.mail_server or "",
"port": settings.mail_port or 587,
"use_tls": settings.mail_use_tls if settings.mail_use_tls is not None else True,
"use_ssl": settings.mail_use_ssl if settings.mail_use_ssl is not None else False,
"username": settings.mail_username or "",
"password_set": bool(settings.mail_password),
"default_sender": settings.mail_default_sender or "",
"test_recipient": (getattr(settings, "mail_test_recipient", None) or ""),
}
),
200,
)
# ==================== Email Template Management ====================
@admin_bp.route("/admin/email-templates")
@login_required
@admin_or_permission_required("manage_settings")
def list_email_templates():
"""List all email templates"""
from app.models import InvoiceTemplate
templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all()
return render_template("admin/email_templates/list.html", templates=templates)
@admin_bp.route("/admin/email-templates/create", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("manage_settings")
def create_email_template():
"""Create a new email template"""
from app.models import InvoiceTemplate
if request.method == "POST":
name = request.form.get("name", "").strip()
description = request.form.get("description", "").strip()
html = request.form.get("html", "").strip()
css = request.form.get("css", "").strip()
is_default = request.form.get("is_default") == "on"
# Validate
if not name:
flash(_("Template name is required"), "error")
return render_template(
"admin/email_templates/create.html", name=name, description=description, html=html, css=css
)
if not html:
flash(_("HTML template content is required"), "error")
return render_template(
"admin/email_templates/create.html", name=name, description=description, html=html, css=css
)
# Check for duplicate name
existing = InvoiceTemplate.query.filter_by(name=name).first()
if existing:
flash(_("A template with this name already exists"), "error")
return render_template(
"admin/email_templates/create.html", name=name, description=description, html=html, css=css
)
# If setting as default, unset other defaults
if is_default:
InvoiceTemplate.query.update({InvoiceTemplate.is_default: False})
# Create template
template = InvoiceTemplate(
name=name,
description=description if description else None,
html=html if html else None,
css=css if css else None,
is_default=is_default,
)
db.session.add(template)
if not safe_commit("create_email_template", {"name": name}):
flash(_("Could not create email template due to a database error."), "error")
return render_template(
"admin/email_templates/create.html", name=name, description=description, html=html, css=css
)
flash(_("Email template created successfully"), "success")
return redirect(url_for("admin.list_email_templates"))
return render_template("admin/email_templates/create.html")
@admin_bp.route("/admin/email-templates/<int:template_id>/send-test", methods=["POST"])
@limiter.limit("5 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def send_email_template_test(template_id):
"""Send a test email using a saved invoice email template."""
from app.models import Settings
from app.utils.email import send_invoice_template_test_email
data = request.get_json() or {}
recipient = (data.get("recipient") or "").strip()
if not recipient:
settings = Settings.get_settings()
recipient = (getattr(settings, "mail_test_recipient", None) or "").strip()
if not recipient:
return jsonify({"success": False, "message": "Recipient email is required"}), 400
invoice_id = data.get("invoice_id")
if invoice_id is not None and invoice_id != "":
try:
invoice_id = int(invoice_id)
except (TypeError, ValueError):
return jsonify({"success": False, "message": "Invalid invoice_id"}), 400
else:
invoice_id = None
custom_message = data.get("custom_message")
if custom_message is not None:
custom_message = str(custom_message).strip() or None
success, message = send_invoice_template_test_email(
template_id, recipient, invoice_id=invoice_id, custom_message=custom_message
)
if success:
return jsonify({"success": True, "message": message}), 200
return jsonify({"success": False, "message": message}), 500
@admin_bp.route("/admin/email-templates/<int:template_id>")
@login_required
@admin_or_permission_required("manage_settings")
def view_email_template(template_id):
"""View email template details"""
from app.models import InvoiceTemplate
template = InvoiceTemplate.query.get_or_404(template_id)
return render_template("admin/email_templates/view.html", template=template)
@admin_bp.route("/admin/email-templates/<int:template_id>/edit", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("manage_settings")
def edit_email_template(template_id):
"""Edit email template"""
from app.models import InvoiceTemplate
template = InvoiceTemplate.query.get_or_404(template_id)
if request.method == "POST":
name = request.form.get("name", "").strip()
description = request.form.get("description", "").strip()
html = request.form.get("html", "").strip()
css = request.form.get("css", "").strip()
is_default = request.form.get("is_default") == "on"
# Validate
if not name:
flash(_("Template name is required"), "error")
return render_template("admin/email_templates/edit.html", template=template)
# Check for duplicate name (excluding current template)
existing = InvoiceTemplate.query.filter(InvoiceTemplate.name == name, InvoiceTemplate.id != template_id).first()
if existing:
flash(_("A template with this name already exists"), "error")
return render_template("admin/email_templates/edit.html", template=template)
# If setting as default, unset other defaults
if is_default:
InvoiceTemplate.query.filter(InvoiceTemplate.id != template_id).update({InvoiceTemplate.is_default: False})
# Update template
template.name = name
template.description = description if description else None
template.html = html if html else None
template.css = css if css else None
template.is_default = is_default
template.updated_at = datetime.utcnow()
if not safe_commit("edit_email_template", {"template_id": template_id}):
flash(_("Could not update email template due to a database error."), "error")
return render_template("admin/email_templates/edit.html", template=template)
flash(_("Email template updated successfully"), "success")
return redirect(url_for("admin.view_email_template", template_id=template_id))
return render_template("admin/email_templates/edit.html", template=template)
@admin_bp.route("/admin/email-templates/<int:template_id>/delete", methods=["POST"])
@login_required
@admin_or_permission_required("manage_settings")
def delete_email_template(template_id):
"""Delete email template"""
from app.models import InvoiceTemplate
template = InvoiceTemplate.query.get_or_404(template_id)
template_name = template.name
# Check if template is in use
if template.invoices.count() > 0 or template.recurring_invoices.count() > 0:
flash(_("Cannot delete template that is in use by invoices or recurring invoices"), "error")
return redirect(url_for("admin.list_email_templates"))
db.session.delete(template)
if not safe_commit("delete_email_template", {"template_id": template_id}):
flash(_("Could not delete email template due to a database error."), "error")
else:
flash(_('Email template "%(name)s" deleted successfully', name=template_name), "success")
return redirect(url_for("admin.list_email_templates"))
# ==================== Integration Setup Routes ====================
@admin_bp.route("/admin/integrations")
@login_required
@admin_or_permission_required("manage_integrations")
def list_integrations_admin():
"""List all integrations (admin view). Redirect to main integrations page."""
return redirect(url_for("integrations.list_integrations"))
@admin_bp.route("/admin/integrations/<provider>/setup", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("manage_integrations")
def integration_setup(provider):
"""Setup page for configuring integration OAuth credentials. Redirect to main integrations manage page."""
return redirect(url_for("integrations.manage_integration", provider=provider))