feat: Advanced Report Builder with iterative generation and email distribution

Implement comprehensive enhancements to the Report Builder system with support
for iterative report generation, flexible email distribution, and improved
error handling.

Features:
- Add iterative report generation: generate one report per custom field value
- Add email distribution modes: mapping, template, and single recipient modes
- Add recipient email templates with {value} placeholder support
- Enhance scheduled reports with better error handling and validation
- Add fix endpoint for invalid scheduled reports
- Improve report builder UI with iterative generation options
- Add comprehensive management views for report schemes

Fixes:
- Fix template errors in iterative report view (dict access issues)
- Fix empty report builder when editing saved reports
- Fix PWA install toast notification handling
- Fix migration revision ID length issue (shortened to fit 32 char limit)
- Add idempotent migration checks to prevent duplicate column errors
- Improve error handling in scheduled reports list view

Database Changes:
- Add iterative_report_generation and iterative_custom_field_name to saved_report_views
- Add email_distribution_mode and recipient_email_template to report_email_schedules
- Migration 090_report_builder_iteration (idempotent)

UI/UX Improvements:
- Display iterative generation status in saved views list
- Show distribution mode and template in scheduled reports
- Add error badges and fix buttons for invalid schedules
- Improve report builder form loading for saved configurations

Technical:
- Enhance ScheduledReportService with recipient resolution logic
- Add validation for report configurations
- Improve error handling and logging throughout
- Update templates to use safe dictionary access patterns
This commit is contained in:
Dries Peeters
2025-12-12 22:11:57 +01:00
parent a582e2af62
commit 88656c3d34
11 changed files with 885 additions and 55 deletions

View File

@@ -0,0 +1,261 @@
# Advanced Report Builder Implementation Summary
## Overview
This document summarizes the implementation of the Advanced Report Builder enhancements, including iterative report generation, custom field filtering, and improved scheduled report distribution.
## Features Implemented
### 1. Iterative Report Generation
**What it does:**
- Generates one report per unique value of a specified custom field (e.g., one report per salesman)
- Automatically extracts all unique values from clients and generates separate reports
- Perfect for scenarios where you need separate reports for each salesman, region, or other custom field value
**How to use:**
1. In Report Builder, create or edit a report
2. In the Save modal, enable "Iterative Report Generation"
3. Select the custom field to iterate over (e.g., "salesman")
4. When viewing the report, you'll see separate sections for each unique value
**Technical Details:**
- New fields in `SavedReportView` model:
- `iterative_report_generation` (Boolean)
- `iterative_custom_field_name` (String)
- Route: `/reports/builder/<view_id>` automatically detects iterative mode
- Template: `reports/iterative_view.html` displays all reports grouped by field value
### 2. Custom Field-Based Filtering
**What it does:**
- Filter reports by any custom field on clients
- Works in both Report Builder and Scheduled Reports
- Supports filtering for unpaid hours reports
**How to use:**
1. In Report Builder filters, select a custom field (e.g., "salesman")
2. Enter the value to filter by (e.g., "MM")
3. The report will only show data for clients matching that custom field value
**Technical Details:**
- Enhanced `generate_report_data()` function in `custom_reports.py`
- Supports filtering in `UnpaidHoursService`
- Works with both direct client custom fields and project->client relationships
### 3. Enhanced Scheduled Report Distribution
**What it does:**
- Supports three email distribution modes:
- **Mapping**: Uses `SalesmanEmailMapping` table to map custom field values to email addresses
- **Template**: Uses dynamic email templates (e.g., `{value}@test.de`)
- **Single**: Sends all reports to the same recipients (fallback)
**How to use:**
1. Create a scheduled report with "Split by Custom Field" enabled
2. Choose email distribution mode:
- **Mapping**: Set up mappings in Salesman Email Mapping (if available)
- **Template**: Enter template like `{value}@test.de`
- **Single**: Use default recipients field
**Technical Details:**
- New fields in `ReportEmailSchedule` model:
- `email_distribution_mode` (String: 'mapping', 'template', 'single')
- `recipient_email_template` (String: e.g., '{value}@test.de')
- Enhanced `_get_recipients_for_field_value()` method in `ScheduledReportService`
- Automatically resolves email addresses based on distribution mode
### 4. Improved Unpaid Hours Workflow
**What it does:**
- Clear checkbox option for "Unpaid Hours Only" in Report Builder
- Better integration with custom field filtering
- Clearer UI with helpful tooltips
**How to use:**
1. In Report Builder, select "Time Entries" as data source
2. Check "Unpaid Hours Only" checkbox
3. Optionally add custom field filter to segment by salesman
4. Preview or save the report
**Technical Details:**
- Uses `UnpaidHoursService` for accurate unpaid hours calculation
- Filters out entries that are:
- Already in invoices (via `InvoiceItem.time_entry_ids`)
- Marked as paid
- Not billable
### 5. Enhanced Management Views
**What it does:**
- Comprehensive list of saved report views with edit/delete options
- Shows iterative generation status
- Better error handling in scheduled reports view
- Ability to fix or remove invalid scheduled reports
**How to use:**
1. Navigate to "Saved Views" from Report Builder
2. View all your saved reports with their features
3. Edit, view, or delete reports as needed
4. In Scheduled Reports, use the "Fix" button to resolve invalid schedules
**Technical Details:**
- Enhanced `list_saved_views()` route
- New `fix_scheduled()` route to handle invalid schedules
- Improved error handling in `list_scheduled()` route
- Validates saved views and filters out invalid ones
### 6. Better Error Handling
**What it does:**
- Prevents errors from breaking the Scheduled Reports view
- Validates report configurations before displaying
- Provides clear error messages and fix options
**Technical Details:**
- Enhanced error handling in `scheduled_reports.py`
- Validates JSON configs before processing
- Gracefully handles missing saved views
- Provides fix/remove options for invalid schedules
## Database Changes
### Migration: `090_enhance_report_builder_iteration`
**New Columns:**
1. **saved_report_views table:**
- `iterative_report_generation` (Boolean, default: false)
- `iterative_custom_field_name` (String, nullable)
2. **report_email_schedules table:**
- `email_distribution_mode` (String, nullable) - Values: 'mapping', 'template', 'single'
- `recipient_email_template` (String, nullable) - Template like '{value}@test.de'
## API Endpoints
### New/Enhanced Routes
1. **GET `/reports/builder/<view_id>`**
- Now supports iterative report generation
- Automatically detects if iterative mode is enabled
2. **GET `/api/reports/builder/custom-field-values`**
- Returns unique values for a custom field
- Query parameter: `field_name`
3. **POST `/reports/scheduled/<schedule_id>/fix`**
- Fixes or removes invalid scheduled reports
- Validates saved view and config
4. **POST `/reports/builder/save`**
- Enhanced to accept iterative report generation settings
- New fields: `iterative_report_generation`, `iterative_custom_field_name`
## Files Modified
### Backend
- `app/models/reporting.py` - Added new model fields
- `app/routes/custom_reports.py` - Enhanced with iterative generation
- `app/routes/scheduled_reports.py` - Improved error handling, added fix route
- `app/services/scheduled_report_service.py` - Enhanced email distribution
- `migrations/versions/090_enhance_report_builder_iteration.py` - New migration
### Frontend
- `app/templates/reports/builder.html` - Added iterative generation UI
- `app/templates/reports/saved_views_list.html` - Shows iterative status
- `app/templates/reports/scheduled.html` - Enhanced with distribution info and fix button
- `app/templates/reports/iterative_view.html` - New template for iterative reports
## Usage Examples
### Example 1: Unpaid Hours Report by Salesman
1. Create a new report in Report Builder
2. Select "Time Entries" as data source
3. Enable "Unpaid Hours Only"
4. Enable "Iterative Report Generation"
5. Select "salesman" as the custom field
6. Save the report
7. View the report to see separate sections for each salesman
### Example 2: Scheduled Reports with Email Mapping
1. Create a scheduled report
2. Enable "Split by Custom Field" and select "salesman"
3. Set email distribution mode to "mapping"
4. Ensure `SalesmanEmailMapping` entries exist (MM -> mm@test.de, PB -> pb@test.de)
5. Schedule will automatically send reports to the correct email for each salesman
### Example 3: Scheduled Reports with Email Template
1. Create a scheduled report
2. Enable "Split by Custom Field" and select "salesman"
3. Set email distribution mode to "template"
4. Enter template: `{value}@test.de`
5. Reports will be sent to MM@test.de, PB@test.de, etc.
## Testing Recommendations
1. **Unit Tests:**
- Test iterative report generation logic
- Test email distribution modes
- Test custom field filtering
2. **Integration Tests:**
- Test full workflow: create report → enable iterative → view report
- Test scheduled reports with different distribution modes
- Test error handling for invalid schedules
3. **Smoke Tests:**
- Create unpaid hours report with custom field filter
- Create iterative report and verify all values are shown
- Create scheduled report and verify email distribution
## Known Limitations
1. **Email Mapping:**
- Requires `SalesmanEmailMapping` entries to be set up manually
- Falls back to default recipients if mapping not found
2. **Custom Field Values:**
- Only extracts values from active clients
- Values must be present in client custom_fields JSON
3. **Iterative Reports:**
- Currently only works for time entries data source
- Other data sources will need similar implementation
## Future Enhancements
1. Support iterative generation for other data sources (projects, invoices, etc.)
2. Add UI for managing email mappings directly in scheduled reports
3. Add preview for iterative reports before saving
4. Support multiple custom fields for iteration
5. Add export functionality for iterative reports
## Migration Instructions
1. Run the migration:
```bash
flask db upgrade
```
2. Verify the migration:
```bash
flask db current
```
3. Test the new features:
- Create a test report with iterative generation
- Create a test scheduled report with email distribution
- Verify error handling works correctly
## Support
For issues or questions:
1. Check the error logs in `logs/timetracker.log`
2. Verify custom field values exist in client records
3. Check that email mappings are set up correctly (if using mapping mode)
4. Ensure saved report views have valid JSON configurations

View File

@@ -12,6 +12,8 @@ class SavedReportView(db.Model):
owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
scope = db.Column(db.String(20), default="private", nullable=False) # private, team, public
config_json = db.Column(db.Text, nullable=False) # JSON for filters, columns, groupings
iterative_report_generation = db.Column(db.Boolean, default=False, nullable=False) # Generate one report per custom field value
iterative_custom_field_name = db.Column(db.String(50), nullable=True) # Custom field name for iteration (e.g., 'salesman')
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -35,6 +37,8 @@ class ReportEmailSchedule(db.Model):
active = db.Column(db.Boolean, default=True, nullable=False)
split_by_salesman = db.Column(db.Boolean, default=False, nullable=False) # Split report by salesman
salesman_field_name = db.Column(db.String(50), nullable=True) # Custom field name for salesman (default: 'salesman')
email_distribution_mode = db.Column(db.String(20), nullable=True) # 'mapping', 'template', 'single'
recipient_email_template = db.Column(db.String(255), nullable=True) # e.g., '{value}@test.de' for template mode
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@@ -94,6 +94,10 @@ def save_report_view():
if not isinstance(config, dict):
return jsonify({"success": False, "message": "Config must be a dictionary"}), 400
# Extract iterative report generation settings
iterative_report_generation = data.get("iterative_report_generation", False)
iterative_custom_field_name = data.get("iterative_custom_field_name", "").strip() or None
# If view_id is provided, update existing report
if view_id:
existing = SavedReportView.query.get(view_id)
@@ -108,6 +112,8 @@ def save_report_view():
existing.name = name
existing.config_json = json.dumps(config)
existing.scope = scope
existing.iterative_report_generation = iterative_report_generation
existing.iterative_custom_field_name = iterative_custom_field_name
existing.updated_at = datetime.utcnow()
saved_view = existing
action = "updated"
@@ -124,7 +130,12 @@ def save_report_view():
else:
# Create new
saved_view = SavedReportView(
name=name, owner_id=current_user.id, scope=scope, config_json=json.dumps(config)
name=name,
owner_id=current_user.id,
scope=scope,
config_json=json.dumps(config),
iterative_report_generation=iterative_report_generation,
iterative_custom_field_name=iterative_custom_field_name,
)
db.session.add(saved_view)
action = "created"
@@ -152,7 +163,7 @@ def save_report_view():
@custom_reports_bp.route("/reports/builder/<int:view_id>")
@login_required
def view_custom_report(view_id):
"""View a custom report."""
"""View a custom report. Supports iterative generation if enabled."""
saved_view = SavedReportView.query.get_or_404(view_id)
# Check access
@@ -166,7 +177,12 @@ def view_custom_report(view_id):
except:
config = {}
# Generate report data based on config
# Check if iterative report generation is enabled
if saved_view.iterative_report_generation and saved_view.iterative_custom_field_name:
# Generate reports for each custom field value
return _generate_iterative_reports(saved_view, config, current_user.id)
# Generate single report data based on config
report_data = generate_report_data(config, current_user.id)
return render_template("reports/custom_view.html", saved_view=saved_view, config=config, report_data=report_data)
@@ -403,6 +419,46 @@ def list_saved_views():
return render_template("reports/saved_views_list.html", saved_views=saved_views, convert_app_datetime_to_user=convert_app_datetime_to_user)
@custom_reports_bp.route("/reports/builder/<int:view_id>/edit", methods=["GET"])
@login_required
def edit_saved_view(view_id):
"""Edit a saved report view - redirects to builder with view_id."""
saved_view = SavedReportView.query.get_or_404(view_id)
# Check permission
if saved_view.owner_id != current_user.id and saved_view.scope == "private":
flash(_("You do not have permission to edit this report."), "error")
return redirect(url_for("custom_reports.list_saved_views"))
# Redirect to builder with edit mode
return redirect(url_for("custom_reports.report_builder", view_id=view_id))
@custom_reports_bp.route("/api/reports/builder/custom-field-values", methods=["GET"])
@login_required
def get_custom_field_values():
"""Get unique values for a custom field from clients."""
custom_field_name = request.args.get("field_name")
if not custom_field_name:
return jsonify({"success": False, "message": "field_name parameter is required"}), 400
# Get all active clients
clients = Client.query.filter_by(status="active").all()
unique_values = set()
for client in clients:
if client.custom_fields and custom_field_name in client.custom_fields:
value = client.custom_fields[custom_field_name]
if value:
unique_values.add(str(value).strip())
return jsonify({
"success": True,
"field_name": custom_field_name,
"values": sorted(list(unique_values))
})
@custom_reports_bp.route("/reports/builder/<int:view_id>/delete", methods=["POST"])
@login_required
def delete_saved_view(view_id):
@@ -430,3 +486,87 @@ def delete_saved_view(view_id):
flash(_("Could not delete report view due to a database error"), "error")
return redirect(url_for("custom_reports.list_saved_views"))
def _generate_iterative_reports(saved_view: SavedReportView, config: dict, user_id: int):
"""
Generate multiple reports, one per custom field value.
Returns a template with all reports grouped by custom field value.
"""
from app.models import Client, TimeEntry
from flask import render_template
custom_field_name = saved_view.iterative_custom_field_name
# Get date range from config
filters = config.get("filters", {})
start_date = filters.get("start_date")
end_date = filters.get("end_date")
try:
if start_date and isinstance(start_date, str) and start_date.strip():
start_dt = datetime.strptime(start_date.strip(), "%Y-%m-%d")
else:
start_dt = datetime.utcnow() - timedelta(days=30)
except (ValueError, AttributeError):
start_dt = datetime.utcnow() - timedelta(days=30)
try:
if end_date and isinstance(end_date, str) and end_date.strip():
end_dt = datetime.strptime(end_date.strip(), "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1)
else:
end_dt = datetime.utcnow()
except (ValueError, AttributeError):
end_dt = datetime.utcnow()
# Get all unique values for the custom field
clients = Client.query.filter_by(status="active").all()
unique_values = set()
# Collect unique values from clients
for client in clients:
if client.custom_fields and custom_field_name in client.custom_fields:
value = client.custom_fields[custom_field_name]
if value:
unique_values.add(str(value).strip())
# Also check from time entries in the date range
time_entries = TimeEntry.query.filter(
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_dt,
TimeEntry.start_time <= end_dt
).all()
for entry in time_entries:
client = None
if entry.project and entry.project.client_obj:
client = entry.project.client_obj
elif entry.client:
client = entry.client
if client and client.custom_fields and custom_field_name in client.custom_fields:
value = client.custom_fields[custom_field_name]
if value:
unique_values.add(str(value).strip())
# Generate report for each value
iterative_reports = {}
for field_value in sorted(unique_values):
# Create modified config with custom field filter
modified_config = config.copy()
if "filters" not in modified_config:
modified_config["filters"] = {}
modified_config["filters"]["custom_field_filter"] = {custom_field_name: field_value}
# Generate report data
report_data = generate_report_data(modified_config, user_id)
iterative_reports[field_value] = report_data
return render_template(
"reports/iterative_view.html",
saved_view=saved_view,
config=config,
iterative_reports=iterative_reports,
custom_field_name=custom_field_name,
)

View File

@@ -7,6 +7,9 @@ from flask_babel import gettext as _
from flask_login import login_required, current_user
from app.models import SavedReportView, ReportEmailSchedule
from app.services.scheduled_report_service import ScheduledReportService
import logging
logger = logging.getLogger(__name__)
scheduled_reports_bp = Blueprint("scheduled_reports", __name__)
@@ -50,11 +53,43 @@ def api_list_scheduled():
@scheduled_reports_bp.route("/reports/scheduled")
@login_required
def list_scheduled():
"""List scheduled reports"""
service = ScheduledReportService()
schedules = service.list_schedules(user_id=current_user.id)
return render_template("reports/scheduled.html", schedules=schedules)
"""List scheduled reports with error handling"""
try:
service = ScheduledReportService()
schedules = service.list_schedules(user_id=current_user.id)
# Validate schedules and filter out invalid ones
valid_schedules = []
for schedule in schedules:
try:
# Check if saved_view exists and is valid
if schedule.saved_view:
# Try to parse config to validate
import json
try:
config = json.loads(schedule.saved_view.config_json) if isinstance(schedule.saved_view.config_json, str) else schedule.saved_view.config_json
if not isinstance(config, dict):
logger.warning(f"Invalid config for schedule {schedule.id}, skipping")
continue
except:
logger.warning(f"Could not parse config for schedule {schedule.id}, skipping")
continue
else:
logger.warning(f"Schedule {schedule.id} has no saved_view, skipping")
continue
valid_schedules.append(schedule)
except Exception as e:
from flask import current_app
current_app.logger.error(f"Error validating schedule {schedule.id}: {e}", exc_info=True)
continue
return render_template("reports/scheduled.html", schedules=valid_schedules)
except Exception as e:
from flask import current_app
current_app.logger.error(f"Error loading scheduled reports: {e}", exc_info=True)
flash(_("Error loading scheduled reports. Please check the logs."), "error")
return render_template("reports/scheduled.html", schedules=[])
@scheduled_reports_bp.route("/reports/scheduled/create", methods=["GET", "POST"])
@@ -72,6 +107,8 @@ def create_scheduled():
timezone = request.form.get("timezone", "").strip() or None
split_by_custom_field = request.form.get("split_by_custom_field") == "1"
custom_field_name = request.form.get("custom_field_name", "").strip() or None
email_distribution_mode = request.form.get("email_distribution_mode", "").strip() or None
recipient_email_template = request.form.get("recipient_email_template", "").strip() or None
if not saved_view_id or not recipients or not cadence:
flash(_("Please fill in all required fields."), "error")
@@ -90,6 +127,8 @@ def create_scheduled():
timezone=timezone,
split_by_custom_field=split_by_custom_field,
custom_field_name=custom_field_name,
email_distribution_mode=email_distribution_mode,
recipient_email_template=recipient_email_template,
)
if result["success"]:
@@ -130,6 +169,8 @@ def api_create_scheduled():
timezone = data.get("timezone", "").strip() or None
split_by_custom_field = data.get("split_by_custom_field", False)
custom_field_name = data.get("custom_field_name", "").strip() or None
email_distribution_mode = data.get("email_distribution_mode", "").strip() or None
recipient_email_template = data.get("recipient_email_template", "").strip() or None
if not saved_view_id or not recipients or not cadence:
return jsonify({"success": False, "error": _("Please fill in all required fields.")}), 400
@@ -146,6 +187,8 @@ def api_create_scheduled():
timezone=timezone,
split_by_custom_field=split_by_custom_field,
custom_field_name=custom_field_name,
email_distribution_mode=email_distribution_mode,
recipient_email_template=recipient_email_template,
)
if result["success"]:
@@ -216,3 +259,48 @@ def api_saved_views():
]
}
)
@scheduled_reports_bp.route("/reports/scheduled/<int:schedule_id>/fix", methods=["POST"])
@login_required
def fix_scheduled(schedule_id):
"""Fix or remove an invalid scheduled report"""
from app import db
import json
schedule = ReportEmailSchedule.query.get_or_404(schedule_id)
# Check permission
if schedule.created_by != current_user.id and not current_user.is_admin:
flash(_("You do not have permission to fix this schedule."), "error")
return redirect(url_for("scheduled_reports.list_scheduled"))
# Try to validate the saved view
if not schedule.saved_view:
# Saved view doesn't exist - delete the schedule
db.session.delete(schedule)
db.session.commit()
flash(_("Scheduled report deleted: saved view no longer exists."), "success")
return redirect(url_for("scheduled_reports.list_scheduled"))
# Try to parse config
try:
config = json.loads(schedule.saved_view.config_json) if isinstance(schedule.saved_view.config_json, str) else schedule.saved_view.config_json
if not isinstance(config, dict):
# Invalid config - deactivate the schedule
schedule.active = False
db.session.commit()
flash(_("Scheduled report deactivated: invalid configuration."), "warning")
return redirect(url_for("scheduled_reports.list_scheduled"))
except:
# Could not parse config - deactivate
schedule.active = False
db.session.commit()
flash(_("Scheduled report deactivated: could not parse configuration."), "warning")
return redirect(url_for("scheduled_reports.list_scheduled"))
# If we get here, the schedule is valid - reactivate it
schedule.active = True
db.session.commit()
flash(_("Scheduled report validated and reactivated."), "success")
return redirect(url_for("scheduled_reports.list_scheduled"))

View File

@@ -36,6 +36,8 @@ class ScheduledReportService:
timezone: Optional[str] = None,
split_by_custom_field: bool = False,
custom_field_name: Optional[str] = None,
email_distribution_mode: Optional[str] = None,
recipient_email_template: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a scheduled report.
@@ -71,6 +73,8 @@ class ScheduledReportService:
created_by=created_by,
split_by_salesman=split_by_custom_field, # Reuse existing field
salesman_field_name=custom_field_name, # Reuse existing field
email_distribution_mode=email_distribution_mode or ("single" if not split_by_custom_field else None),
recipient_email_template=recipient_email_template,
)
db.session.add(schedule)
@@ -115,6 +119,11 @@ class ScheduledReportService:
if schedule.split_by_salesman and schedule.salesman_field_name:
return self._generate_and_send_custom_field_reports(schedule, saved_view, config)
# Validate config before proceeding
if not isinstance(config, dict):
logger.error(f"Invalid config for schedule {schedule_id}: config is not a dict")
return {"success": False, "message": "Invalid report configuration. Please check the saved report view."}
# Generate report data based on config
report_data = self._generate_report_data(saved_view, config)
@@ -434,8 +443,13 @@ class ScheduledReportService:
html_body = None
text_body = f"Scheduled Report: {saved_view.name} - {custom_field_name}={field_value}\n\nGenerated at: {now_in_app_timezone()}\n\nReport data available in HTML version."
# Send email to all recipients
for recipient in recipients:
# Determine recipient(s) based on distribution mode
report_recipients = self._get_recipients_for_field_value(
schedule, field_value, recipients
)
# Send email to determined recipients
for recipient in report_recipients:
try:
send_email(
subject=f"Scheduled Report: {saved_view.name} - {custom_field_name}={field_value}",
@@ -467,5 +481,50 @@ class ScheduledReportService:
}
except Exception as e:
db.session.rollback()
logger.error(f"Error generating and sending custom field reports: {e}")
logger.error(f"Error generating and sending custom field reports: {e}", exc_info=True)
return {"success": False, "message": f"Error generating reports: {str(e)}"}
def _get_recipients_for_field_value(
self, schedule: ReportEmailSchedule, field_value: str, default_recipients: List[str]
) -> List[str]:
"""
Get recipient email addresses for a specific custom field value.
Supports three modes:
- 'mapping': Use SalesmanEmailMapping table
- 'template': Use recipient_email_template with {value} placeholder
- 'single': Use default recipients (fallback)
Args:
schedule: ReportEmailSchedule object
field_value: The custom field value (e.g., 'MM', 'PB')
default_recipients: Fallback recipients if mapping/template fails
Returns:
List of email addresses
"""
distribution_mode = schedule.email_distribution_mode or "single"
if distribution_mode == "mapping":
# Use SalesmanEmailMapping
from app.models import SalesmanEmailMapping
email = SalesmanEmailMapping.get_email_for_initial(field_value)
if email:
return [email]
else:
logger.warning(f"No email mapping found for {field_value}, using default recipients")
return default_recipients
elif distribution_mode == "template":
# Use email template
template = schedule.recipient_email_template
if template and "{value}" in template:
email = template.replace("{value}", field_value)
return [email]
else:
logger.warning(f"Invalid email template '{template}', using default recipients")
return default_recipients
else:
# Single mode: use default recipients
return default_recipients

View File

@@ -1493,7 +1493,7 @@
deferredPrompt = e;
// Show install button in UI
if (window.toastManager) {
if (window.toastManager && typeof window.toastManager.show === 'function') {
// Create a non-dismissible toast so we can add custom buttons
const toastId = window.toastManager.show({
message: 'Install TimeTracker as an app!',
@@ -1501,45 +1501,67 @@
duration: 0,
dismissible: false
});
// Check if toast was created successfully
if (!toastId) {
console.warn('Failed to create PWA install toast');
return;
}
// Get the toast element after it's added to DOM
requestAnimationFrame(() => {
// Use setTimeout to ensure DOM is updated after requestAnimationFrame in show()
setTimeout(() => {
const toastElement = document.querySelector(`[data-toast-id="${toastId}"]`);
if (toastElement) {
const btn = document.createElement('button');
btn.textContent = 'Install';
btn.className = 'ml-2 px-3 py-1 bg-primary text-white rounded hover:bg-primary/90';
btn.onclick = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
if (!toastElement) {
console.warn('PWA install toast element not found');
return;
}
const btn = document.createElement('button');
btn.textContent = 'Install';
btn.className = 'ml-2 px-3 py-1 bg-primary text-white rounded hover:bg-primary/90';
btn.onclick = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
if (window.toastManager && typeof window.toastManager.success === 'function') {
window.toastManager.success('App installed successfully!');
}
// Always mark as dismissed after user interaction
localStorage.setItem('pwa-install-dismissed', 'true');
deferredPrompt = null;
window.toastManager.dismiss(toastId);
};
// Add a dismiss button
const dismissBtn = document.createElement('button');
dismissBtn.textContent = '×';
dismissBtn.className = 'ml-2 px-2 py-1 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded';
dismissBtn.title = 'Dismiss permanently';
dismissBtn.onclick = () => {
localStorage.setItem('pwa-install-dismissed', 'true');
deferredPrompt = null;
window.toastManager.dismiss(toastId);
};
// Insert buttons in the toast content area
const content = toastElement.querySelector('.toast-content');
if (content) {
content.appendChild(btn);
content.appendChild(dismissBtn);
}
// Always mark as dismissed after user interaction
localStorage.setItem('pwa-install-dismissed', 'true');
deferredPrompt = null;
if (window.toastManager && typeof window.toastManager.dismiss === 'function') {
window.toastManager.dismiss(toastId);
}
};
// Add a dismiss button
const dismissBtn = document.createElement('button');
dismissBtn.textContent = '×';
dismissBtn.className = 'ml-2 px-2 py-1 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded';
dismissBtn.title = 'Dismiss permanently';
dismissBtn.onclick = () => {
localStorage.setItem('pwa-install-dismissed', 'true');
deferredPrompt = null;
if (window.toastManager && typeof window.toastManager.dismiss === 'function') {
window.toastManager.dismiss(toastId);
}
};
// Insert buttons in the toast content area
const content = toastElement.querySelector('.toast-content');
if (content) {
content.appendChild(btn);
content.appendChild(dismissBtn);
} else {
// Fallback: append to toast element directly if content not found
console.warn('Toast content element not found, appending buttons to toast element');
toastElement.appendChild(btn);
toastElement.appendChild(dismissBtn);
}
});
}, 100); // Small delay to ensure DOM is ready
}
});

View File

@@ -178,6 +178,33 @@
<option value="public" {% if saved_view and saved_view.scope == 'public' %}selected{% endif %}>{{ _('Public') }}</option>
</select>
</div>
<!-- Iterative Report Generation -->
<div class="mb-4 pt-4 border-t border-border-light dark:border-border-dark">
<label class="flex items-center cursor-pointer mb-3">
<input type="checkbox" id="iterativeReportGeneration" class="form-checkbox mr-2" {% if saved_view and saved_view.iterative_report_generation %}checked{% endif %}>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-sync-alt text-blue-500 mr-1"></i>
{{ _('Iterative Report Generation') }}
</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3 ml-6">
{{ _('Generate one report per custom field value (e.g., one report per salesman)') }}
</p>
<div id="iterativeFieldContainer" class="ml-6 {% if not saved_view or not saved_view.iterative_report_generation %}hidden{% endif %}">
<label for="iterativeCustomFieldName" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Custom Field Name') }}</label>
<select id="iterativeCustomFieldName" class="form-input">
<option value="">{{ _('Select Field') }}</option>
{% for field_key in custom_field_keys %}
<option value="{{ field_key }}" {% if saved_view and saved_view.iterative_custom_field_name == field_key %}selected{% endif %}>{{ field_key|replace('_', ' ')|title }}</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ _('Select the custom field to iterate over (e.g., "salesman")') }}
</p>
</div>
</div>
<div class="flex justify-end gap-4">
<button type="button" onclick="closeSaveModal()" class="px-4 py-2 border border-border-light dark:border-border-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">{{ _('Cancel') }}</button>
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90">{{ _('Save') }}</button>
@@ -193,10 +220,10 @@ let reportConfig = {
filters: {}
};
{% if saved_view and config %}
{% if saved_view %}
// Load saved configuration when editing
(function() {
const savedConfig = {{ config|tojson }};
const savedConfig = {{ (config or {})|tojson }};
reportConfig = {
data_source: savedConfig.data_source || null,
components: savedConfig.components ? [...savedConfig.components] : [],
@@ -586,6 +613,16 @@ function closeSaveModal() {
document.getElementById('saveModal').classList.add('hidden');
}
// Toggle iterative field container based on checkbox
document.getElementById('iterativeReportGeneration')?.addEventListener('change', function(e) {
const container = document.getElementById('iterativeFieldContainer');
if (e.target.checked) {
container.classList.remove('hidden');
} else {
container.classList.add('hidden');
}
});
document.getElementById('saveForm').addEventListener('submit', async (e) => {
e.preventDefault();
@@ -656,7 +693,9 @@ document.getElementById('saveForm').addEventListener('submit', async (e) => {
name: name,
scope: scope,
config: reportConfig,
view_id: {% if saved_view %}{{ saved_view.id }}{% else %}null{% endif %}
view_id: {% if saved_view %}{{ saved_view.id }}{% else %}null{% endif %},
iterative_report_generation: document.getElementById('iterativeReportGeneration')?.checked || false,
iterative_custom_field_name: document.getElementById('iterativeCustomFieldName')?.value || null
})
});

View File

@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ saved_view.name }} - {{ _('Iterative Report') }} - {{ app_name }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Reports', 'url': url_for('reports.reports')},
{'text': 'Report Builder', 'url': url_for('custom_reports.report_builder')},
{'text': 'Saved Views', 'url': url_for('custom_reports.list_saved_views')},
{'text': saved_view.name}
] %}
{{ page_header(
icon_class='fas fa-sync-alt',
title_text=saved_view.name,
subtitle_text=_('Iterative Report - One report per %(field_name)s value', field_name=custom_field_name),
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("custom_reports.report_builder", view_id=saved_view.id) + '" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"><i class="fas fa-edit mr-2"></i>' + _('Edit') + '</a>'
) }}
<div class="space-y-6">
{% if iterative_reports %}
{% for field_value, report_data in iterative_reports.items() %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex justify-between items-center mb-4 pb-4 border-b border-border-light dark:border-border-dark">
<h3 class="text-lg font-semibold">
<i class="fas fa-tag text-primary mr-2"></i>
{{ custom_field_name|replace('_', ' ')|title }}: <span class="text-primary">{{ field_value }}</span>
</h3>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ report_data.summary.get('total_entries', 0) }} {{ _('entries') }} •
{{ report_data.summary.get('total_hours', 0) }} {{ _('hours') }}
</div>
</div>
{% if report_data.data and report_data.data|length > 0 %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-border-light dark:divide-border-dark">
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('Date') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('Client') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('Project') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('User') }}</th>
<th class="px-4 py-3 text-right text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('Hours') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('Notes') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-border-light dark:divide-border-dark">
{% for entry in report_data.data %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 whitespace-nowrap text-sm">{{ entry.get('date', '') }}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">{{ entry.get('client', '') }}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">{{ entry.get('project', '') }}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">{{ entry.get('user', '') }}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-right">{{ "%.2f"|format(entry.get('duration', 0) or 0) }}</td>
<td class="px-4 py-3 text-sm text-text-muted-light dark:text-text-muted-dark">{{ (entry.get('notes', '') or '')[:50] }}{% if (entry.get('notes', '') or '')|length > 50 %}...{% endif %}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="bg-gray-50 dark:bg-gray-800 font-semibold">
<td colspan="4" class="px-4 py-3 text-right">{{ _('Total') }}:</td>
<td class="px-4 py-3 text-right">{{ "%.2f"|format(report_data.summary.get('total_hours', 0) or 0) }} {{ _('hours') }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
{% if report_data.summary.get('by_client') %}
<div class="mt-6 pt-6 border-t border-border-light dark:border-border-dark">
<h4 class="text-sm font-semibold mb-3">{{ _('Summary by Client') }}</h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for client_name, client_data in report_data.summary.get('by_client', {}).items() %}
<div class="bg-gray-50 dark:bg-gray-800 p-3 rounded">
<div class="font-medium">{{ client_name }}</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ "%.2f"|format(client_data.get('hours', 0) or 0) }} {{ _('hours') }}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% else %}
<div class="text-center py-8 text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-inbox text-3xl mb-2"></i>
<p>{{ _('No data found for this %(field_name)s value', field_name=custom_field_name) }}</p>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow text-center">
<i class="fas fa-exclamation-triangle text-3xl text-yellow-500 mb-4"></i>
<p class="text-text-muted-light dark:text-text-muted-dark">
{{ _('No unique values found for custom field "%(field_name)s"', field_name=custom_field_name) }}
</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -26,6 +26,7 @@
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Scope') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Features') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Created') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Updated') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Actions') }}</th>
@@ -40,6 +41,15 @@
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded bg-primary/10 text-primary">{{ view.scope|title }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-wrap gap-1">
{% if view.iterative_report_generation %}
<span class="px-2 py-1 text-xs rounded bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200" title="{{ _('Iterative Report Generation') }}">
<i class="fas fa-sync-alt mr-1"></i>{{ _('Iterative') }}
</span>
{% endif %}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm">{% if view.created_at %}{{ convert_app_datetime_to_user(view.created_at, user=current_user).strftime('%Y-%m-%d %H:%M') }}{% endif %}</div>
</td>

View File

@@ -36,9 +36,24 @@
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap">
<div class="font-medium">{{ schedule.saved_view.name if schedule.saved_view else _('Unknown') }}</div>
{% if schedule.split_by_salesman and schedule.salesman_field_name %}
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
<i class="fas fa-sync-alt mr-1"></i>{{ _('Split by') }}: {{ schedule.salesman_field_name }}
</div>
{% endif %}
{% if schedule.email_distribution_mode and schedule.email_distribution_mode != 'single' %}
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
<i class="fas fa-envelope mr-1"></i>{{ _('Distribution') }}: {{ schedule.email_distribution_mode|title }}
</div>
{% endif %}
</td>
<td class="px-6 py-4">
<div class="text-sm">{{ schedule.recipients }}</div>
{% if schedule.recipient_email_template %}
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('Template') }}: {{ schedule.recipient_email_template }}
</div>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded bg-primary/10 text-primary">{{ schedule.cadence|title }}</span>
@@ -51,19 +66,33 @@
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if schedule.active %}
{% if not schedule.saved_view %}
<span class="px-2 py-1 text-xs rounded bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200" title="{{ _('Invalid: saved view not found') }}">
<i class="fas fa-exclamation-triangle mr-1"></i>{{ _('Error') }}
</span>
{% elif schedule.active %}
<span class="px-2 py-1 text-xs rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Active') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ _('Inactive') }}</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<form method="POST" action="{{ url_for('scheduled_reports.delete_scheduled', schedule_id=schedule.id) }}" class="inline" onsubmit="return confirm('{{ _('Are you sure you want to delete this scheduled report?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-red-600 hover:text-red-800">
<i class="fas fa-trash"></i>
</button>
</form>
<div class="flex justify-end gap-2">
{% if not schedule.saved_view %}
<form method="POST" action="{{ url_for('scheduled_reports.fix_scheduled', schedule_id=schedule.id) }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-yellow-600 hover:text-yellow-800" title="{{ _('Fix or remove invalid schedule') }}">
<i class="fas fa-wrench"></i>
</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('scheduled_reports.delete_scheduled', schedule_id=schedule.id) }}" class="inline" onsubmit="return confirm('{{ _('Are you sure you want to delete this scheduled report?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-red-600 hover:text-red-800" title="{{ _('Delete') }}">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}

View File

@@ -0,0 +1,73 @@
"""Enhance Report Builder with iterative generation and email distribution
Revision ID: 090_report_builder_iteration
Revises: 089_fix_role_perm_sequences
Create Date: 2025-01-30
This migration adds:
- iterative_report_generation field to saved_report_views (enable one report per custom field value)
- email_distribution_mode field to report_email_schedules (mapping, template, or single)
- recipient_email_template field to report_email_schedules (for dynamic email generation)
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '090_report_builder_iteration'
down_revision = '089_fix_role_perm_sequences'
branch_labels = None
depends_on = None
def _has_column(inspector, table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table"""
try:
return column_name in [col['name'] for col in inspector.get_columns(table_name)]
except Exception:
return False
def upgrade():
"""Add iterative report generation and email distribution fields"""
bind = op.get_bind()
inspector = sa.inspect(bind)
# Add iterative report generation to saved_report_views
if 'saved_report_views' in inspector.get_table_names():
if not _has_column(inspector, 'saved_report_views', 'iterative_report_generation'):
op.add_column('saved_report_views',
sa.Column('iterative_report_generation', sa.Boolean(), nullable=False, server_default='false'))
if not _has_column(inspector, 'saved_report_views', 'iterative_custom_field_name'):
op.add_column('saved_report_views',
sa.Column('iterative_custom_field_name', sa.String(length=50), nullable=True))
# Add email distribution options to report_email_schedules
if 'report_email_schedules' in inspector.get_table_names():
if not _has_column(inspector, 'report_email_schedules', 'email_distribution_mode'):
op.add_column('report_email_schedules',
sa.Column('email_distribution_mode', sa.String(length=20), nullable=True)) # 'mapping', 'template', 'single'
if not _has_column(inspector, 'report_email_schedules', 'recipient_email_template'):
op.add_column('report_email_schedules',
sa.Column('recipient_email_template', sa.String(length=255), nullable=True)) # e.g., '{value}@test.de'
def downgrade():
"""Remove iterative report generation and email distribution fields"""
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'report_email_schedules' in inspector.get_table_names():
if _has_column(inspector, 'report_email_schedules', 'recipient_email_template'):
op.drop_column('report_email_schedules', 'recipient_email_template')
if _has_column(inspector, 'report_email_schedules', 'email_distribution_mode'):
op.drop_column('report_email_schedules', 'email_distribution_mode')
if 'saved_report_views' in inspector.get_table_names():
if _has_column(inspector, 'saved_report_views', 'iterative_custom_field_name'):
op.drop_column('saved_report_views', 'iterative_custom_field_name')
if _has_column(inspector, 'saved_report_views', 'iterative_report_generation'):
op.drop_column('saved_report_views', 'iterative_report_generation')